Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
c699a7c
refactor: added new logic for soft-delete (#265)
Justy116 Jun 27, 2026
2b58576
reject project restore on name or slug conflict
Polliog Jun 27, 2026
cfa3369
let soft-deleted projects be viewed read-only
Polliog Jun 27, 2026
c8b2962
merge soft-delete follow-up fixes
Polliog Jun 27, 2026
f18f78b
add receivers tables and schema types
Polliog Jul 2, 2026
dc83d73
add receiver adapter constants and mapping schema
Polliog Jul 2, 2026
e7791ba
add receiver adapter core and generic adapter
Polliog Jul 2, 2026
82cc9d2
add github receiver adapter
Polliog Jul 2, 2026
bb2c71f
add uptime receiver adapter
Polliog Jul 2, 2026
2eee273
add receivers service
Polliog Jul 2, 2026
79e77aa
add receiver management routes with capability limit
Polliog Jul 2, 2026
67271bc
add public receiver endpoint and event processing job
Polliog Jul 2, 2026
98cbe93
add receiver management ui in project settings
Polliog Jul 2, 2026
20e8be9
document webhook receivers
Polliog Jul 2, 2026
7d8cb99
annotate receiver touch update for scoping tripwire
Polliog Jul 2, 2026
c512d3c
add receiver tables to table manifest test
Polliog Jul 2, 2026
7a2df92
Merge pull request #268 from logtide-dev/feat/receivers-155
Polliog Jul 3, 2026
2aa25bf
use exact count below planner estimate threshold
Polliog Jul 6, 2026
752985b
fix dashboard panel uuid in non-secure http context
Polliog Jul 6, 2026
e7f8b26
honor requested window for hostname dropdown
Polliog Jul 7, 2026
839059a
backfill activity overview recent tail from raw
Polliog Jul 7, 2026
576c47f
label top services widget with its 7-day window
Polliog Jul 10, 2026
f7993d0
cut 1.1.0 release
Polliog Jul 10, 2026
2a9a8e5
bump version to 1.1.0
Polliog Jul 10, 2026
ceb4e81
bump remaining version references to 1.1.0
Polliog Jul 10, 2026
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
13 changes: 12 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).


## [Unreleased]
## [1.1.0] - 2026-07-08

### Added
- **Inbound webhook receivers** (#155): external systems (CI/CD, uptime monitors, arbitrary tools) can now push events into LogTide through per-receiver tokenized endpoints (`POST /api/v1/receivers/:id/:token`, `lr_`-prefixed tokens stored as SHA-256 hashes, timing-safe compare; the URL is the credential since GitHub/Uptime Robot cannot send custom headers). Three adapters normalize payloads into log entries: GitHub (workflow_run and deployment_status events with conclusion-to-level mapping; ping and unsupported events marked skipped), Uptime (shape-detects Uptime Robot alertType and Better Stack incident payloads), and Generic JSON (optional dot-path field mapping with levelMap and defaults, validated by a shared Zod schema; full payload always preserved in metadata). The endpoint answers 202 and processing is asynchronous via a new `receiver-events` queue job (queue abstraction, both backends), which runs the adapter, validates against `logSchema` and ingests through `ingestionService.ingestLogs`, so PII masking (fail-closed), usage quotas, Sigma detection, pipelines, metering and live tail all apply automatically; the outcome (processed/skipped/failed, normalized output, error) is recorded on a `receiver_events` row pruned to the last 100 per receiver. Management: project-scoped CRUD + recent-events API under `/api/v1/projects/:projectId/receivers` (session auth, audit-logged as `receiver.created`/`receiver.deleted`), a new `receivers.max` capability limit enforced with the canonical withLimitLock pattern (4-case + race tests), and a "Webhook Receivers" section in project settings (create dialog with adapter picker and generic field-mapping inputs, one-time URL reveal with copy, enable/disable switch, recent-events viewer with raw/normalized JSON). Migration 052 (`receivers`, `receiver_events`); public docs in `docs/receivers.md`
- **Soft-delete for projects with a 30-day grace window**: deleting a project now moves it to a recoverable "trash" state (`deleted_at`) instead of removing it immediately. Its logs, traces and metrics stay queryable and the project stays viewable read-only during the window (the project detail layout loads with `includeDeleted`, shows a "deleted (read-only)" banner with the permanent-deletion date, and hides the Settings tab); a "Restore" action brings it back. A daily purge worker (3 AM) hard-deletes projects past the grace window, calling `reservoir.purgeProject()` to clear logs/spans/metrics from every storage engine before deleting the row. Name and slug uniqueness moved to partial unique indexes (active projects only) so a deleted project's name/slug can be reused; restoring into a name or slug a new active project has since taken is rejected with a 409 instead of a raw constraint error. Soft-deleted projects are excluded from listings, data-availability widgets and API-key verification (ingestion is refused immediately, with the key-verification cache invalidated on delete). New `POST /api/v1/projects/:id/restore` and `GET /api/v1/projects?includeDeleted=true`. Migrations 050 (soft-delete column + partial indexes) and 051 (drop `ON DELETE CASCADE` from logs/spans/metrics so an out-of-band project delete cannot bulk-wipe data; the purge worker clears reservoir data explicitly first)

### Fixed
- **Log total no longer disagrees with the visible logs on TimescaleDB** (#271): the displayed result count came from the Postgres query planner's row estimate, which is unreliable at small scale (stale `ANALYZE` stats, few rows per chunk) and could be wrong in both directions, so the header showed a total that did not match the listed logs. `countEstimate` now falls back to an exact `COUNT` below a 50k-row estimate and keeps the fast planner estimate above it, so small and moderate deployments see an accurate total while large datasets stay performant.
- **Dashboard editing over plain HTTP on a LAN IP** (#272): adding a panel called `crypto.randomUUID()`, which only exists in a secure context (HTTPS or localhost), so it threw when LogTide was accessed over plain HTTP by IP. Panel IDs are now generated through a `uuid()` helper that falls back to `crypto.getRandomValues()` (available in non-secure contexts) when `randomUUID` is missing.
- **Hosts missing from the filter dropdown** (#273): `getDistinctHostnames` clamped its lookup window to 6 hours regardless of the selected range, so hosts that had only logged 6-24h ago were dropped from the hosts dropdown even though their logs were still visible in the (24h) logs view. The window now honors the requested range (default 24h, matching the log query default) so the dropdown lists every host with logs in the same window; hostnames stay cached for 5 minutes to bound the JSONB distinct cost.
- **Activity Overview chart stuck 1-2h behind live data** (#274): on TimescaleDB the panel read its logs/spans/detections series entirely from continuous aggregates, whose refresh policy (`end_offset = 1 bucket`, running once per bucket) never materializes the most recent 1-2 buckets, so the chart's latest edge showed empty while logs were still arriving (the raw-backed logs view stayed current, which read like a timezone offset). The panel now reads the cagg only up to a bucket-aligned cutoff two buckets back and backfills the recent tail live from the raw tables, keeping the fast aggregate for the bulk of the window while the latest buckets track real time. ClickHouse/MongoDB are unaffected (no continuous aggregates); alerts already read raw.
- **Top Services table window was unlabeled** (#275): the "Top Services by Volume" widget covers a fixed 7-day window (and its total is a 7-day total), but it sat next to the 24h-framed activity chart with no window label, so its service list and total looked inconsistent with the logs page (which follows the selected range, 24h by default) and read like stale/stuck data. The widget title now states its window ("Last 7 Days") so the different counts are self-explanatory; the 7-day window itself is intentional (daily continuous aggregate for the historical span) and unchanged.

## [1.0.3] - 2026-06-26

Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,14 @@
<a href="https://codecov.io/gh/logtide-dev/logtide"><img src="https://codecov.io/gh/logtide-dev/logtide/branch/main/graph/badge.svg" alt="Coverage"></a>
<a href="https://hub.docker.com/r/logtide/backend"><img src="https://img.shields.io/docker/v/logtide/backend?label=docker&logo=docker" alt="Docker"></a>
<a href="https://artifacthub.io/packages/helm/logtide/logtide"><img src="https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/logtide" alt="Artifact Hub"></a>
<img src="https://img.shields.io/badge/version-1.0.3-blue.svg" alt="Version">
<img src="https://img.shields.io/badge/version-1.1.0-blue.svg" alt="Version">
<img src="https://img.shields.io/badge/license-AGPLv3-blue.svg" alt="License">
<img src="https://img.shields.io/badge/status-beta-success.svg" alt="Status">
</div>

<br />

> **🌊 LogTide 1.0.3 (public beta):** unified **Logs, Traces & Metrics** with a built-in **SIEM**, multi-engine storage (TimescaleDB / ClickHouse / MongoDB), uptime monitoring, parsing pipelines, and custom dashboards.
> **🌊 LogTide 1.1.0 (public beta):** unified **Logs, Traces & Metrics** with a built-in **SIEM**, multi-engine storage (TimescaleDB / ClickHouse / MongoDB), uptime monitoring, parsing pipelines, and custom dashboards.

---

Expand Down Expand Up @@ -124,7 +124,7 @@ We host it for you. Perfect for testing. [**Sign up at logtide.dev**](https://lo

---

## ✨ Core Features (v1.0.3)
## ✨ Core Features (v1.1.0)

### Monitoring, Pipelines & Dashboards
* 🩺 **Uptime Monitoring & Status Pages:** HTTP/TCP/heartbeat monitors with configurable thresholds, auto-created SIEM incidents on failure, scheduled maintenances, and public Uptime-Kuma-style status pages per project.
Expand Down
122 changes: 122 additions & 0 deletions docs/receivers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# Webhook Receivers

Webhook receivers let external systems (CI/CD pipelines, uptime monitors, arbitrary tools) push events into LogTide. Each receiver exposes a unique tokenized URL; incoming payloads are normalized into standard log entries by an adapter and stored through the regular ingestion pipeline, so PII masking, usage quotas, Sigma detection and parsing rules all apply automatically.

Receivers are project-scoped and managed from **Project Settings > Webhook Receivers**, where you can create receivers, copy their URL, enable or disable them, and inspect the most recent received events with their normalized output.

## Endpoint contract

```
POST /api/v1/receivers/{receiverId}/{token}
Content-Type: application/json
```

The URL itself is the credential: `receiverId` identifies the receiver and `token` authenticates the request. No headers are required, which makes the endpoint compatible with systems that cannot send custom headers (GitHub webhooks, Uptime Robot).

The body must be a single JSON object of at most 256 KB.

### Responses

| Status | Meaning |
| --- | --- |
| `202 Accepted` | Event stored and queued for processing. Body: `{ "eventId": "..." }` |
| `400 Bad Request` | Body is not a JSON object (arrays, scalars and null are rejected) |
| `401 Unauthorized` | Token does not match the receiver |
| `403 Forbidden` | Receiver is disabled |
| `404 Not Found` | Unknown receiver id |
| `413 Payload Too Large` | Serialized payload exceeds 256 KB |

Processing is asynchronous: a `202` means the payload was accepted, not that it produced a log entry. The outcome (`processed`, `skipped` or `failed`, with the normalized output and any error) is visible in the receiver's recent-events view, which keeps the last 100 events per receiver.

## Tokens

Receiver tokens use the `lr_` prefix (distinct from `lp_` project API keys) and are shown exactly once, at creation time, embedded in the full webhook URL. Only a SHA-256 hash is stored. Treat the URL as a secret: anyone who has it can write logs into your project. To rotate a token, delete the receiver and create a new one, then update the external system with the new URL.

A receiver token cannot read data or call any other API endpoint; a leaked URL limits the blast radius to log ingestion into one project.

## Adapters

The adapter chosen at creation time decides how raw payloads become log entries.

### GitHub

Point a GitHub repository webhook (JSON content type) at the receiver URL. Supported events:

- **workflow_run** (only `action: completed`): `success` maps to `info`, `cancelled`/`skipped`/`neutral` to `warn`, `failure`/`timed_out`/`startup_failure`/`action_required` to `error`. The message looks like `Workflow CI completed: failure`.
- **deployment_status**: `success` maps to `info`, `failure`/`error` to `error`, anything else to `info`. The message looks like `Deployment to production: failure`.

The `service` field is the repository `full_name` (e.g. `acme/app`). Metadata includes the event type, workflow name, conclusion, run id and URL, branch and actor. Ping events and unsupported event types are marked `skipped`, not failed.

Example: a failed CI run

```json
{
"action": "completed",
"workflow_run": { "name": "CI", "conclusion": "failure", "html_url": "...", "head_branch": "main" },
"repository": { "full_name": "acme/app" },
"sender": { "login": "octocat" }
}
```

becomes an `error` log for service `acme/app` with message `Workflow CI completed: failure`.

### Uptime

Detects two payload shapes automatically:

- **Uptime Robot** (`alertType` field): `1` (down) maps to `error`, `2` (up) to `info`, `3` (SSL expiry) to `warn`. The monitor friendly name becomes the service.
- **Better Stack** (`data.attributes` incident shape): status `Started` maps to `error`, `Resolved` and `Acknowledged` to `info`. The monitor name becomes the service and the incident cause is part of the message.

Unrecognized shapes are marked `skipped`.

### Generic JSON

Accepts any JSON object. Without configuration it produces one `info` log with message `Received event`, the receiver name as service, and the full payload preserved under `metadata.payload`.

An optional **field mapping** extracts log fields from the payload using dot-paths:

```json
{
"message": "error.message",
"level": "severity",
"service": "source.app",
"timestamp": "ts",
"levelMap": { "crit": "critical", "sev1": "error" },
"defaults": { "level": "warn", "service": "external-system" }
}
```

| Key | Meaning |
| --- | --- |
| `message` | Dot-path to the log message. Missing or non-string values fall back to `Received event`. |
| `level` | Dot-path to the level value. Values are lowercased and matched against LogTide levels (`debug`, `info`, `warn`, `error`, `critical`); the synonyms `warning`, `fatal`, `err` and `crit` are also understood. |
| `service` | Dot-path to the service name (truncated to 100 chars). Falls back to `defaults.service`, then the receiver name. |
| `timestamp` | Dot-path to an ISO string or epoch value. Unparseable values fall back to the arrival time. |
| `levelMap` | Case-insensitive map applied to the extracted level value before the builtin matching (e.g. map `sev1` to `error`). |
| `defaults` | Fallback `level` and `service` used when the mapped values are missing or invalid. |

The create dialog exposes the four path fields; `levelMap` and `defaults` can be set through the API (`PATCH /api/v1/projects/{projectId}/receivers/{id}` with a `fieldMapping` object).

## Pipeline guarantees

Received events are ingested through the same path as `POST /api/v1/ingest`:

- **PII masking is fail-closed**: entries whose masking fails are rejected, and the event is marked `failed` with the rejection reason.
- **Usage quotas** (`ingestion.max_bytes_monthly`, `ingestion.max_events_monthly`, `storage.max_bytes`) apply; over-quota events fail with the quota error recorded.
- Sigma detection, exception parsing, log pipelines, metering and live tail all see receiver events like any other ingested log.

The number of receivers per organization can be capped with the `receivers.max` capability (unlimited by default in OSS).

## Management API

All management endpoints require session authentication and project access.

| Method and path | Purpose |
| --- | --- |
| `GET /api/v1/projects/{projectId}/receivers` | List receivers (never returns tokens) |
| `POST /api/v1/projects/{projectId}/receivers` | Create; returns `token` and `ingestPath` once |
| `PATCH /api/v1/projects/{projectId}/receivers/{id}` | Rename, enable/disable, update `fieldMapping` |
| `DELETE /api/v1/projects/{projectId}/receivers/{id}` | Delete (invalidates the URL immediately) |
| `GET /api/v1/projects/{projectId}/receivers/{id}/events?limit=50` | Recent events with raw payload, normalized output and status |

Receiver creation and deletion are recorded in the audit log (`receiver.created`, `receiver.deleted`).
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "logtide",
"version": "1.0.3",
"version": "1.1.0",
"private": true,
"description": "LogTide - Self-hosted log management platform",
"author": "LogTide Team",
Expand Down
35 changes: 35 additions & 0 deletions packages/backend/migrations/050_soft_delete_projects.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
-- ============================================================================
-- Migration 050: Soft-delete support for projects
-- ============================================================================
-- Adds a deleted_at column so project deletion is reversible during a grace
-- window (default 30 days). A background worker later hard-deletes rows
-- past the grace window (see migration 051 and the purge worker in worker.ts).
--
-- Also replaces hard uniqueness constraints on slug and (org, name) with
-- partial-index variants (WHERE deleted_at IS NULL) so a soft-deleted project
-- no longer "occupies" its name or slug, allowing the same values to be reused
-- for new projects.
-- ============================================================================

-- 1. Add deleted_at column (NULL = active, non-NULL = soft-deleted)
ALTER TABLE projects ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ NULL;

-- 2. Index to support the purge worker's "find projects past grace window" query
CREATE INDEX IF NOT EXISTS idx_projects_deleted_at
ON projects (deleted_at)
WHERE deleted_at IS NOT NULL;

-- 3. Replace the global slug unique index (added in migration 036) with a
-- per-org partial one. Slugs need only be unique within an organization,
-- and soft-deleted projects no longer occupy their slug.
DROP INDEX IF EXISTS idx_projects_slug_unique;
CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_slug_unique
ON projects (organization_id, slug)
WHERE deleted_at IS NULL;

-- 4. Replace the (organization_id, name) unique constraint from the original
-- schema with a partial unique index.
ALTER TABLE projects DROP CONSTRAINT IF EXISTS projects_organization_id_name_key;
CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_org_name_unique
ON projects (organization_id, name)
WHERE deleted_at IS NULL;
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
-- ============================================================================
-- Migration 051: Drop ON DELETE CASCADE from logs / spans / metrics project FK
-- ============================================================================
-- CAUTION: This migration swaps FK constraints on TimescaleDB hypertables.
-- On large deployments (50M+ rows) the constraint rebuild can take considerable
-- time. Apply during a low-traffic window and verify the query plan beforehand.
--
-- Rationale: with soft-delete in place (migration 050), the projects row is
-- never immediately removed — it stays in the DB throughout the 30-day grace
-- window. The hard-delete purge worker explicitly calls reservoir.purgeProject()
-- (which deletes logs/spans/metrics by project_id) *before* removing the
-- projects row, making the cascade redundant.
-- Dropping it prevents an accidental bulk data-loss if a projects row is
-- ever hard-deleted outside the controlled purge path.
-- ============================================================================

-- logs (TimescaleDB hypertable)
ALTER TABLE logs DROP CONSTRAINT IF EXISTS logs_project_id_fkey;
ALTER TABLE logs ADD CONSTRAINT logs_project_id_fkey
FOREIGN KEY (project_id) REFERENCES projects(id);

-- spans (TimescaleDB hypertable)
ALTER TABLE spans DROP CONSTRAINT IF EXISTS spans_project_id_fkey;
ALTER TABLE spans ADD CONSTRAINT spans_project_id_fkey
FOREIGN KEY (project_id) REFERENCES projects(id);

-- metrics (TimescaleDB hypertable)
ALTER TABLE metrics DROP CONSTRAINT IF EXISTS metrics_project_id_fkey;
ALTER TABLE metrics ADD CONSTRAINT metrics_project_id_fkey
FOREIGN KEY (project_id) REFERENCES projects(id);
30 changes: 30 additions & 0 deletions packages/backend/migrations/052_receivers.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
-- Inbound webhook receivers (#155): external systems POST events that get
-- normalized into log entries by per-receiver adapters.
CREATE TABLE IF NOT EXISTS receivers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
adapter_type TEXT NOT NULL CHECK (adapter_type IN ('github', 'uptime', 'generic')),
token_hash TEXT NOT NULL UNIQUE,
field_mapping JSONB,
enabled BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_received_at TIMESTAMPTZ
);

CREATE INDEX IF NOT EXISTS idx_receivers_project ON receivers(project_id);

-- Recent raw/normalized events per receiver, capped at 100 rows per receiver
-- by the worker (pruneEvents). Powers the "recent events" UI.
CREATE TABLE IF NOT EXISTS receiver_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
receiver_id UUID NOT NULL REFERENCES receivers(id) ON DELETE CASCADE,
status TEXT NOT NULL CHECK (status IN ('pending', 'processed', 'skipped', 'failed')),
raw_payload JSONB NOT NULL,
normalized JSONB,
error TEXT,
received_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_receiver_events_receiver
ON receiver_events(receiver_id, received_at DESC);
Loading
Loading