+ Its logs, traces and metrics stay available
+ {#if purgeDate}until they are permanently deleted on {purgeDate}{:else}for 30 days{/if}.
+ Restore the project to make it active again.
+
+
+
+
+ {/if}
{project.name}
{#if project.description}
diff --git a/packages/frontend/src/routes/dashboard/projects/[id]/settings/+page.svelte b/packages/frontend/src/routes/dashboard/projects/[id]/settings/+page.svelte
index 65650590..0f8db77a 100644
--- a/packages/frontend/src/routes/dashboard/projects/[id]/settings/+page.svelte
+++ b/packages/frontend/src/routes/dashboard/projects/[id]/settings/+page.svelte
@@ -18,7 +18,10 @@
import Spinner from '$lib/components/Spinner.svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import CreateApiKeyDialog from '$lib/components/CreateApiKeyDialog.svelte';
- import { Trash2, Plus } from '@lucide/svelte/icons';
+ import { CreateReceiverDialog, ReceiverEventsDialog } from '$lib/components/receivers';
+ import Switch from '$lib/components/ui/switch/switch.svelte';
+ import { receiversAPI, type Receiver, type ReceiverAdapterType, type ReceiverFieldMapping } from '$lib/api/receivers';
+ import { Trash2, Plus, ScrollText } from '@lucide/svelte/icons';
let project = $state(null);
let loading = $state(false);
@@ -36,6 +39,14 @@
let apiKeyToDelete = $state(null);
let lastLoadedApiKeysKey = $state(null);
+ let receivers = $state([]);
+ let loadingReceivers = $state(false);
+ let showCreateReceiverDialog = $state(false);
+ let receiverToDelete = $state(null);
+ let eventsReceiver = $state(null);
+ let showEventsDialog = $state(false);
+ let lastLoadedReceiversKey = $state(null);
+
const projectId = $derived(page.params.id);
async function loadProject() {
@@ -92,6 +103,18 @@
loadApiKeys();
});
+ $effect(() => {
+ if (!browser || !projectId) {
+ receivers = [];
+ lastLoadedReceiversKey = null;
+ return;
+ }
+
+ if (projectId === lastLoadedReceiversKey) return;
+
+ loadReceivers();
+ });
+
async function handleSave() {
if (!$currentOrganization || !projectId) return;
@@ -169,6 +192,73 @@
}
}
+ async function loadReceivers() {
+ if (!projectId) return;
+
+ loadingReceivers = true;
+
+ try {
+ const response = await receiversAPI.list(projectId);
+ receivers = response.receivers;
+ lastLoadedReceiversKey = projectId;
+ } catch (e) {
+ toastStore.error(e instanceof Error ? e.message : 'Failed to load receivers');
+ } finally {
+ loadingReceivers = false;
+ }
+ }
+
+ async function handleCreateReceiver(data: {
+ name: string;
+ adapterType: ReceiverAdapterType;
+ fieldMapping?: ReceiverFieldMapping | null;
+ }) {
+ if (!projectId) throw new Error('No project ID');
+
+ const response = await receiversAPI.create(projectId, data);
+ toastStore.success('Receiver created successfully');
+ await loadReceivers();
+ return response;
+ }
+
+ async function handleToggleReceiver(receiver: Receiver, enabled: boolean) {
+ if (!projectId) return;
+
+ try {
+ await receiversAPI.update(projectId, receiver.id, { enabled });
+ toastStore.success(enabled ? 'Receiver enabled' : 'Receiver disabled');
+ await loadReceivers();
+ } catch (e) {
+ toastStore.error(e instanceof Error ? e.message : 'Failed to update receiver');
+ await loadReceivers();
+ }
+ }
+
+ async function handleDeleteReceiver() {
+ if (!projectId || !receiverToDelete) return;
+
+ try {
+ await receiversAPI.delete(projectId, receiverToDelete.id);
+ toastStore.success('Receiver deleted successfully');
+ await loadReceivers();
+ } catch (e) {
+ toastStore.error(e instanceof Error ? e.message : 'Failed to delete receiver');
+ } finally {
+ receiverToDelete = null;
+ }
+ }
+
+ function openReceiverEvents(receiver: Receiver) {
+ eventsReceiver = receiver;
+ showEventsDialog = true;
+ }
+
+ function adapterLabel(type: ReceiverAdapterType): string {
+ if (type === 'github') return 'GitHub';
+ if (type === 'uptime') return 'Uptime';
+ return 'Generic JSON';
+ }
+
function formatDate(date: string | null): string {
if (!date) return 'Never';
return new Date(date).toLocaleString('en-US');
@@ -360,6 +450,91 @@
+
+
+
+
+ Webhook Receivers
+
+ Receive events from external systems (CI/CD, uptime monitors) as log entries
+
+
+
+
+
+
+ {#if loadingReceivers}
+
+
+ Loading receivers...
+
+ {:else if receivers.length === 0}
+
+
No receivers yet
+
Create a receiver to ingest events from external tools
+
+ {:else}
+
+
+
+
+
Name
+
Source
+
Enabled
+
Last Received
+
Actions
+
+
+
+ {#each receivers as receiver (receiver.id)}
+
+
{receiver.name}
+
+
+ {adapterLabel(receiver.adapterType)}
+
+
+
+ handleToggleReceiver(receiver, checked)}
+ />
+
+
+ {formatDate(receiver.lastReceivedAt)}
+
+
+
+
+
+
+ {/each}
+
+
+
+ {/if}
+
+
+
Danger Zone
@@ -384,6 +559,30 @@
+
+
+
+
+ !open && (receiverToDelete = null)}
+>
+
+
+ Delete Receiver
+
+ Are you sure you want to delete the receiver {receiverToDelete?.name}?
+ Its webhook URL will stop working immediately and cannot be restored. This action cannot
+ be undone.
+
+
+
+ Cancel
+ Delete Receiver
+
+
+
+
!open && (apiKeyToDelete = null)}
diff --git a/packages/frontend/vitest.config.ts b/packages/frontend/vitest.config.ts
index 5cd40c34..597e698f 100644
--- a/packages/frontend/vitest.config.ts
+++ b/packages/frontend/vitest.config.ts
@@ -1,6 +1,10 @@
import { defineConfig } from 'vitest/config';
import { svelte } from '@sveltejs/vite-plugin-svelte';
import { svelteTesting } from '@testing-library/svelte/vite';
+import { fileURLToPath } from 'url';
+import path from 'path';
+
+const __dirname = fileURLToPath(new URL('.', import.meta.url));
export default defineConfig({
plugins: [
@@ -12,11 +16,14 @@ export default defineConfig({
environment: 'jsdom',
include: ['src/**/*.test.ts'],
setupFiles: ['./src/test-setup.ts'],
- alias: {
- $lib: new URL('./src/lib', import.meta.url).pathname,
- },
},
resolve: {
conditions: ['browser'],
+ alias: {
+ // Use fileURLToPath so paths with spaces are decoded correctly
+ // (new URL().pathname encodes spaces as %20 which breaks Vite resolution)
+ $lib: path.resolve(__dirname, 'src/lib'),
+ '@logtide/shared': path.resolve(__dirname, '../../packages/shared/src/index.ts'),
+ },
},
});
diff --git a/packages/reservoir/package.json b/packages/reservoir/package.json
index a7bb6d67..42556a3e 100644
--- a/packages/reservoir/package.json
+++ b/packages/reservoir/package.json
@@ -1,6 +1,6 @@
{
"name": "@logtide/reservoir",
- "version": "1.0.3",
+ "version": "1.1.0",
"description": "Pluggable storage abstraction for Logtide log management",
"type": "module",
"main": "./dist/index.js",
diff --git a/packages/reservoir/src/buffered/reservoir-buffered.ts b/packages/reservoir/src/buffered/reservoir-buffered.ts
index 89aa66db..69d3ac80 100644
--- a/packages/reservoir/src/buffered/reservoir-buffered.ts
+++ b/packages/reservoir/src/buffered/reservoir-buffered.ts
@@ -131,6 +131,7 @@ export class ReservoirBuffered implements IReservoir {
getMetricLabelValues(...args: Parameters): ReturnType { return this.inner.getMetricLabelValues(...args); }
deleteMetricsByTimeRange(...args: Parameters): ReturnType { return this.inner.deleteMetricsByTimeRange(...args); }
getMetricsOverview(...args: Parameters): ReturnType { return this.inner.getMetricsOverview(...args); }
+ purgeProject(...args: Parameters): ReturnType { return this.inner.purgeProject(...args); }
getEngineType(): ReturnType { return this.inner.getEngineType(); }
getEngine(): StorageEngine { return this.inner.getEngine(); }
async close(): Promise { await this.stop(); await this.inner.close(); }
diff --git a/packages/reservoir/src/client.test.ts b/packages/reservoir/src/client.test.ts
index 42b15889..e4f8789e 100644
--- a/packages/reservoir/src/client.test.ts
+++ b/packages/reservoir/src/client.test.ts
@@ -1,4 +1,4 @@
-import { describe, it, expect } from 'vitest';
+import { describe, it, expect, vi } from 'vitest';
import { Reservoir } from './client.js';
import type { StorageConfig, QueryParams, AggregateParams } from './core/types.js';
@@ -69,4 +69,55 @@ describe('Reservoir', () => {
const reservoir = new Reservoir('timescale', config);
await expect(reservoir.close()).resolves.toBeUndefined();
});
+
+ describe('purgeProject', () => {
+ it('throws if called before initialization', async () => {
+ const reservoir = new Reservoir('timescale', config);
+ await expect(reservoir.purgeProject('p1')).rejects.toThrow('Reservoir not initialized');
+ });
+
+ it('delegates to all three engine delete methods and sums deleted counts', async () => {
+ const reservoir = new Reservoir('timescale', config);
+ const r = reservoir as any;
+ r.initialized = true;
+ r.engine = {
+ deleteByTimeRange: vi.fn().mockResolvedValue({ deleted: 5 }),
+ deleteSpansByTimeRange: vi.fn().mockResolvedValue({ deleted: 3 }),
+ deleteMetricsByTimeRange: vi.fn().mockResolvedValue({ deleted: 2 }),
+ };
+
+ const result = await reservoir.purgeProject('test-project-id');
+
+ expect(result.deleted).toBe(10);
+ expect(r.engine.deleteByTimeRange).toHaveBeenCalledWith({
+ projectId: 'test-project-id',
+ from: new Date(0),
+ to: new Date('2100-01-01T00:00:00Z'),
+ });
+ expect(r.engine.deleteSpansByTimeRange).toHaveBeenCalledWith({
+ projectId: 'test-project-id',
+ from: new Date(0),
+ to: new Date('2100-01-01T00:00:00Z'),
+ });
+ expect(r.engine.deleteMetricsByTimeRange).toHaveBeenCalledWith({
+ projectId: 'test-project-id',
+ from: new Date(0),
+ to: new Date('2100-01-01T00:00:00Z'),
+ });
+ });
+
+ it('returns zero deleted when engine returns empty counts', async () => {
+ const reservoir = new Reservoir('timescale', config);
+ const r = reservoir as any;
+ r.initialized = true;
+ r.engine = {
+ deleteByTimeRange: vi.fn().mockResolvedValue({ deleted: 0 }),
+ deleteSpansByTimeRange: vi.fn().mockResolvedValue({ deleted: 0 }),
+ deleteMetricsByTimeRange: vi.fn().mockResolvedValue({ deleted: 0 }),
+ };
+
+ const result = await reservoir.purgeProject('empty-project');
+ expect(result.deleted).toBe(0);
+ });
+ });
});
diff --git a/packages/reservoir/src/client.ts b/packages/reservoir/src/client.ts
index 0c8e5c02..124932c8 100644
--- a/packages/reservoir/src/client.ts
+++ b/packages/reservoir/src/client.ts
@@ -277,6 +277,23 @@ export class Reservoir implements IReservoir {
return this.engine.getMetricsOverview(params);
}
+ /**
+ * Hard-purge all logs, spans, and metrics for a project.
+ * Implements IReservoir.purgeProject by calling the three time-range delete
+ * methods with the full time span so nothing is left behind.
+ */
+ async purgeProject(projectId: string): Promise {
+ this.ensureInitialized();
+ const from = new Date(0);
+ const to = new Date('2100-01-01T00:00:00Z');
+ const [logs, spans, metrics] = await Promise.all([
+ this.engine.deleteByTimeRange({ projectId, from, to }),
+ this.engine.deleteSpansByTimeRange({ projectId, from, to }),
+ this.engine.deleteMetricsByTimeRange({ projectId, from, to }),
+ ]);
+ return { deleted: logs.deleted + spans.deleted + metrics.deleted };
+ }
+
getEngineType(): EngineType {
return this.engine.getCapabilities().engine;
}
diff --git a/packages/reservoir/src/core/reservoir-interface.ts b/packages/reservoir/src/core/reservoir-interface.ts
index a07b830c..2c4ec7c4 100644
--- a/packages/reservoir/src/core/reservoir-interface.ts
+++ b/packages/reservoir/src/core/reservoir-interface.ts
@@ -104,6 +104,14 @@ export interface IReservoir {
deleteMetricsByTimeRange(params: DeleteMetricsByTimeRangeParams): Promise;
getMetricsOverview(params: MetricsOverviewParams): Promise;
+ /**
+ * Hard-purge all logs, spans, and metrics for a project across every
+ * storage engine. Called by the background hard-delete worker after the
+ * soft-delete grace window expires, before the projects row is removed from
+ * Postgres.
+ */
+ purgeProject(projectId: string): Promise;
+
// Lifecycle & introspection
getEngineType(): EngineType;
close(): Promise;
diff --git a/packages/reservoir/src/engines/timescale/timescale-engine.test.ts b/packages/reservoir/src/engines/timescale/timescale-engine.test.ts
index 6af15de6..b540e381 100644
--- a/packages/reservoir/src/engines/timescale/timescale-engine.test.ts
+++ b/packages/reservoir/src/engines/timescale/timescale-engine.test.ts
@@ -369,6 +369,42 @@ describe('TimescaleEngine', () => {
});
});
+ describe('countEstimate', () => {
+ const countParams = {
+ projectId: 'proj-1',
+ from: new Date('2024-01-01'),
+ to: new Date('2024-01-02'),
+ };
+
+ it('returns an exact COUNT when the planner estimate is small', async () => {
+ // EXPLAIN reports a small estimate, so we fall back to an exact count.
+ mockQuery.mockResolvedValueOnce({ rows: [{ 'QUERY PLAN': [{ Plan: { 'Plan Rows': 3 } }] }] });
+ mockQuery.mockResolvedValueOnce({ rows: [{ count: '42' }] });
+ await engine.connect();
+
+ const result = await engine.countEstimate(countParams);
+
+ expect(result.count).toBe(42);
+ expect(mockQuery).toHaveBeenCalledTimes(2);
+ const explainSql = mockQuery.mock.calls[0][0] as string;
+ const countSql = mockQuery.mock.calls[1][0] as string;
+ expect(explainSql).toContain('EXPLAIN (FORMAT JSON)');
+ expect(countSql).toContain('COUNT(*)');
+ expect(countSql).not.toContain('EXPLAIN');
+ });
+
+ it('keeps the fast planner estimate for large datasets', async () => {
+ mockQuery.mockResolvedValueOnce({ rows: [{ 'QUERY PLAN': [{ Plan: { 'Plan Rows': 1_000_000 } }] }] });
+ await engine.connect();
+
+ const result = await engine.countEstimate(countParams);
+
+ expect(result.count).toBe(1_000_000);
+ // Only the EXPLAIN runs; no exact count.
+ expect(mockQuery).toHaveBeenCalledTimes(1);
+ });
+ });
+
describe('aggregate', () => {
it('groups results by time bucket and level', async () => {
const bucket1 = new Date('2024-01-01T00:00:00Z');
diff --git a/packages/reservoir/src/engines/timescale/timescale-engine.ts b/packages/reservoir/src/engines/timescale/timescale-engine.ts
index b6130a53..b0172220 100644
--- a/packages/reservoir/src/engines/timescale/timescale-engine.ts
+++ b/packages/reservoir/src/engines/timescale/timescale-engine.ts
@@ -66,6 +66,11 @@ import { TimescaleQueryTranslator } from './query-translator.js';
const { Pool } = pg;
+// Below this planner estimate we run an exact COUNT instead of trusting the estimate.
+// At small scale an exact count is cheap and the planner estimate is unreliable; above
+// it we keep the fast estimate so huge datasets stay performant.
+const EXACT_COUNT_THRESHOLD = 50_000;
+
function sanitizeNull(value: string): string {
return value.includes('\0') ? value.replace(/\0/g, '') : value;
}
@@ -379,6 +384,16 @@ export class TimescaleEngine extends StorageEngine {
);
const plan = (result.rows[0] as Record)['QUERY PLAN'] as Array<{ Plan: { 'Plan Rows': number } }>;
const estimate = Math.round(plan[0]?.Plan?.['Plan Rows'] ?? 0);
+
+ // The planner estimate is unreliable at small scale (stale ANALYZE stats, few rows
+ // per chunk): it can be wrong in both directions, which users notice as a displayed
+ // total that disagrees with the visible logs. Below a threshold an exact COUNT is
+ // cheap, so return it; above it keep the fast estimate for large datasets.
+ if (estimate < EXACT_COUNT_THRESHOLD) {
+ const exact = await this.count(params);
+ return { count: exact.count, executionTimeMs: Date.now() - start };
+ }
+
return {
count: estimate,
executionTimeMs: Date.now() - start,
diff --git a/packages/reservoir/src/test-global-setup.ts b/packages/reservoir/src/test-global-setup.ts
new file mode 100644
index 00000000..14801aae
--- /dev/null
+++ b/packages/reservoir/src/test-global-setup.ts
@@ -0,0 +1,27 @@
+/**
+ * Vitest global setup: auto-detect infrastructure availability.
+ *
+ * Sets SKIP_REDIS_TESTS=1 when a Redis server is not reachable so that
+ * integration tests degrade gracefully to "skipped" instead of "failed".
+ */
+import Redis from 'ioredis';
+
+const REDIS_URL = process.env.REDIS_URL ?? 'redis://localhost:6380';
+
+export async function setup() {
+ const redis = new Redis(REDIS_URL, {
+ maxRetriesPerRequest: 0,
+ lazyConnect: true,
+ connectTimeout: 1500,
+ enableReadyCheck: false,
+ });
+
+ try {
+ await redis.connect();
+ await redis.ping();
+ } catch {
+ process.env.SKIP_REDIS_TESTS = '1';
+ } finally {
+ redis.disconnect(false);
+ }
+}
diff --git a/packages/reservoir/vitest.config.ts b/packages/reservoir/vitest.config.ts
index ecaa2a77..6483218a 100644
--- a/packages/reservoir/vitest.config.ts
+++ b/packages/reservoir/vitest.config.ts
@@ -1,4 +1,8 @@
import { defineConfig } from 'vitest/config';
+import { fileURLToPath } from 'url';
+import path from 'path';
+
+const __dirname = fileURLToPath(new URL('.', import.meta.url));
export default defineConfig({
test: {
@@ -18,5 +22,14 @@ export default defineConfig({
],
},
testTimeout: 10000,
+ globalSetup: ['./src/test-global-setup.ts'],
+ },
+ resolve: {
+ alias: {
+ // @logtide/shared dist/ is not built; alias directly to source so tests
+ // can resolve the package without a build step.
+ '@logtide/shared/context': path.resolve(__dirname, '../shared/src/context/index.ts'),
+ '@logtide/shared': path.resolve(__dirname, '../shared/src/index.ts'),
+ },
},
});
diff --git a/packages/shared/package.json b/packages/shared/package.json
index 8f236971..f13e1e77 100644
--- a/packages/shared/package.json
+++ b/packages/shared/package.json
@@ -1,6 +1,6 @@
{
"name": "@logtide/shared",
- "version": "1.0.3",
+ "version": "1.1.0",
"private": true,
"description": "Shared types, schemas and utilities for LogTide",
"type": "module",
diff --git a/packages/shared/src/constants/index.ts b/packages/shared/src/constants/index.ts
index 69883500..f6e26b46 100644
--- a/packages/shared/src/constants/index.ts
+++ b/packages/shared/src/constants/index.ts
@@ -7,3 +7,4 @@ export * from './siem-constants.js';
export * from './exception-constants.js';
export * from './mitre-constants.js';
export * from './monitoring-constants.js';
+export * from './receiver-constants.js';
diff --git a/packages/shared/src/constants/receiver-constants.ts b/packages/shared/src/constants/receiver-constants.ts
new file mode 100644
index 00000000..2fa66b6e
--- /dev/null
+++ b/packages/shared/src/constants/receiver-constants.ts
@@ -0,0 +1,2 @@
+export const RECEIVER_ADAPTER_TYPES = ['github', 'uptime', 'generic'] as const;
+export type ReceiverAdapterType = (typeof RECEIVER_ADAPTER_TYPES)[number];
diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts
index 9edd8f51..56d849ab 100644
--- a/packages/shared/src/schemas/index.ts
+++ b/packages/shared/src/schemas/index.ts
@@ -71,3 +71,4 @@ export type AlertRuleInput = z.infer;
export * from './metadata-filter.js';
export * from './webhook-events.js';
+export * from './receiver.js';
diff --git a/packages/shared/src/schemas/receiver.test.ts b/packages/shared/src/schemas/receiver.test.ts
new file mode 100644
index 00000000..aeeaad07
--- /dev/null
+++ b/packages/shared/src/schemas/receiver.test.ts
@@ -0,0 +1,33 @@
+import { describe, it, expect } from 'vitest';
+import { receiverFieldMappingSchema, RECEIVER_ADAPTER_TYPES } from '../index.js';
+
+describe('receiverFieldMappingSchema', () => {
+ it('accepts a full valid mapping', () => {
+ const result = receiverFieldMappingSchema.safeParse({
+ message: 'msg.text',
+ level: 'severity',
+ service: 'source.app',
+ timestamp: 'ts',
+ levelMap: { crit: 'critical', warning: 'warn' },
+ defaults: { level: 'info', service: 'external' },
+ });
+ expect(result.success).toBe(true);
+ });
+
+ it('accepts an empty mapping', () => {
+ expect(receiverFieldMappingSchema.safeParse({}).success).toBe(true);
+ });
+
+ it('rejects unknown keys', () => {
+ expect(receiverFieldMappingSchema.safeParse({ nope: 'x' }).success).toBe(false);
+ });
+
+ it('rejects invalid levels in levelMap and defaults', () => {
+ expect(receiverFieldMappingSchema.safeParse({ levelMap: { a: 'verbose' } }).success).toBe(false);
+ expect(receiverFieldMappingSchema.safeParse({ defaults: { level: 'verbose' } }).success).toBe(false);
+ });
+
+ it('exposes the adapter type list', () => {
+ expect(RECEIVER_ADAPTER_TYPES).toEqual(['github', 'uptime', 'generic']);
+ });
+});
diff --git a/packages/shared/src/schemas/receiver.ts b/packages/shared/src/schemas/receiver.ts
new file mode 100644
index 00000000..4702cecc
--- /dev/null
+++ b/packages/shared/src/schemas/receiver.ts
@@ -0,0 +1,29 @@
+import { z } from 'zod';
+import { LOG_LEVELS, RECEIVER_ADAPTER_TYPES } from '../constants/index.js';
+
+export const receiverAdapterTypeSchema = z.enum(RECEIVER_ADAPTER_TYPES);
+
+const dotPath = z.string().min(1).max(200);
+
+/**
+ * Field mapping for the 'generic' receiver adapter (#155).
+ * Values are dot-paths into the raw payload (e.g. "error.message").
+ */
+export const receiverFieldMappingSchema = z
+ .object({
+ message: dotPath.optional(),
+ level: dotPath.optional(),
+ service: dotPath.optional(),
+ timestamp: dotPath.optional(),
+ levelMap: z.record(z.enum(LOG_LEVELS)).optional(),
+ defaults: z
+ .object({
+ level: z.enum(LOG_LEVELS).optional(),
+ service: z.string().min(1).max(100).optional(),
+ })
+ .strict()
+ .optional(),
+ })
+ .strict();
+
+export type ReceiverFieldMapping = z.infer;
diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts
index 6202c619..a23e0b36 100644
--- a/packages/shared/src/types/index.ts
+++ b/packages/shared/src/types/index.ts
@@ -108,6 +108,7 @@ export interface Project {
statusPageVisibility: StatusPageVisibility;
createdAt: Date;
updatedAt: Date;
+ deletedAt?: Date | null;
}
// Log types