A feature release. External systems can now push events into LogTide through tokenized inbound webhook receivers (GitHub, Uptime, generic JSON), and deleting a project moves it to a recoverable 30-day trash instead of wiping it. Plus five dashboard and log-count correctness fixes.
Inbound webhook receivers: GitHub, Uptime Robot / Better Stack and generic-JSON senders can push events in via per-receiver tokenized URLs
Received events flow through the full pipeline — PII masking, quotas, Sigma detection, metering and live tail all apply
Project soft-delete: deletion moves a project to a recoverable 30-day trash (still queryable, read-only) with a Restore action
Log total is now exact below 50k rows on TimescaleDB instead of an unreliable planner estimate (#271)
Activity Overview chart tracks live data again instead of lagging 1–2h behind (#274)
Dashboard editing works over plain HTTP on a LAN IP again (#272)
Show detailsHide details
A feature release. The two headline additions are inbound webhook receivers — a tokenized ingestion path so external systems (CI/CD, uptime monitors, arbitrary tools) can push events into LogTide — and project soft-delete, which turns project deletion into a recoverable 30-day trash instead of an immediate wipe. Alongside them, five dashboard and log-count correctness fixes.
Added
Inbound webhook receivers (#155): external systems can now push events into LogTide through per-receiver tokenized endpoints (POST /api/v1/receivers/:id/:token). Tokens are lr_-prefixed, stored as SHA-256 hashes and compared timing-safe — the URL is the credential, since senders like GitHub and Uptime Robot can’t add custom headers. Three adapters normalize payloads into log entries: GitHub (workflow_run / deployment_status with conclusion-to-level mapping; ping and unsupported events marked skipped), Uptime (shape-detects Uptime Robot and Better Stack incident payloads), and Generic JSON (optional dot-path field mapping with levelMap and defaults; the full payload is always preserved in metadata). The endpoint answers 202 and processing is asynchronous via a new receiver-events queue job, 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. Each outcome (processed/skipped/failed, normalized output, error) is recorded on a receiver_events row pruned to the last 100 per receiver. Management is project-scoped CRUD + a recent-events API (session auth, audit-logged), a new receivers.max capability limit enforced with the canonical withLimitLock pattern, and a “Webhook Receivers” section in project settings (adapter picker, one-time URL reveal with copy, enable/disable, recent-events viewer). Migration 052
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 detail view shows a “deleted (read-only)” banner with the permanent-deletion date and hides the Settings tab — and a Restore action brings it back. A daily purge worker (3 AM) hard-deletes projects past the 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/slug a new 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). New POST /api/v1/projects/:id/restore and GET /api/v1/projects?includeDeleted=true. Migrations 050 (soft-delete column + partial indexes) and 051 (drops ON DELETE CASCADE from logs/spans/metrics so an out-of-band project delete can’t 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 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 either direction. 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
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 never materializes the most recent 1–2 buckets, so the chart’s latest edge showed empty while logs were still arriving. The panel now reads the aggregate only up to a bucket-aligned cutoff two buckets back and backfills the recent tail live from the raw tables. ClickHouse/MongoDB were unaffected
Hosts missing from the filter dropdown (#273): getDistinctHostnames clamped its lookup to 6 hours regardless of the selected range, so hosts that had only logged 6–24h ago were dropped from the dropdown even though their logs were still visible. The window now honors the requested range (default 24h), and hostnames stay cached for 5 minutes to bound the JSONB distinct cost
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 now go through a uuid() helper that falls back to crypto.getRandomValues() in non-secure contexts
Top Services table window was unlabeled (#275): the “Top Services by Volume” widget covers a fixed 7-day window, but sat next to the 24h-framed activity chart with no label, so its list and total read like stale data. The title now states its window (“Last 7 Days”); the 7-day window itself is intentional and unchanged
v1.0.3
Released
Security
1.0.3: Coordinated-Disclosure Security Fixes
A security-focused release resolving privately reported issues (coordinated disclosure via KIberblick.de): cross-tenant dashboard reads, stored XSS in the service map, an open redirect, SSRF DNS-rebinding, and two races. Trace span attributes are now PII-masked. No migrations — a drop-in upgrade.
Cross-tenant read on the dashboard API endpoints closed — API-key auth now matches the requested org/project against the key's bound scope
Stored XSS via OTLP service.name in the service map fixed; service.name is also sanitized at ingestion as defense in depth
Open redirect on the auth-free login/register path fixed with a shared safe-redirect helper
SSRF guard now pins the validated IP on the HTTP path (DNS-rebinding hardening)
PII masking now also covers trace span attributes, including deep-masked request/response bodies
No database migrations — a drop-in upgrade from 1.0.2
Show detailsHide details
A security-focused release. It resolves a batch of privately reported issues (coordinated disclosure via KIberblick.de): a cross-tenant read on the dashboard API endpoints, stored XSS via OTLP service.name in the service map, an open redirect on the auth-free login/register path, a DNS-rebinding gap in the SSRF guard’s HTTP path, a first-admin bootstrap promotion race, and a capability-limit check-then-act race; trace span attributes are now PII-masked, and service.name is sanitized at ingestion as defense in depth. No database migrations — a drop-in upgrade. Alongside the security work: two correctness follow-ups from the multi-engine bug-hunt sweep (#255) — full SigmaHQ field-modifier chains and a true service-map p95 on every storage engine (validated against real ClickHouse, MongoDB and TimescaleDB) — two operational fixes (a Redis memory leak and a runaway nightly SigmaHQ sync), and a handful of frontend touch-ups.
Security
Cross-tenant read on the dashboard API endpoints: the dashboard endpoints (/api/v1/dashboard/stats, /timeseries, /top-services, /timeline-events, /recent-errors, /activity-overview) took organizationId from the query string and only ran the membership check behind if (request.user?.id), which is set for session auth only. For API-key auth that check was skipped, so a holder of any full-access key (bound to org A) could read another org’s dashboard data by passing its id. All six handlers now route through a shared resolveDashboardScope that, for API-key auth, requires the requested org/project to match the key’s bound scope (defaulting to the bound project when omitted) — mirroring the resolveQueryProjectId guard that already protects the query and traces routes. Reported privately via KIberblick.de
Stored XSS via OTLP service.name in the service map: service.name only had null bytes stripped at ingestion, so < > " ' survived into span.service_name, and ServiceMap.svelte’s ECharts tooltip.formatter returned a raw HTML string built from it — so a service.name like <img src=x onerror=…> executed for any operator who hovered the node/edge. User-derived tooltip values are now HTML-escaped via a shared escapeHtml util (also adopted by the SIEM HTML report builder). Stored data is left raw on purpose — escaping at the sink avoids double-encoding and keeps the JSON API correct. Reported privately via KIberblick.de
Open redirect on the auth-free login/register path: in authMode === 'none' deployments both pages forwarded the user-supplied redirect query parameter via goto() with no validation. The check now lives in a shared isSafeInternalPath/safeRedirect helper used on both paths; it requires a single-leading-slash path and rejects protocol-relative forms including the backslash variant (/\evil.com). Reported privately via KIberblick.de
SSRF guard now pins the validated IP (DNS-rebinding hardening): safeFetch resolved and validated the host, then let fetch() re-resolve at connect time, leaving a resolve-then-connect window for rebinding (the TCP monitor path already pinned; the HTTP path did not). The HTTP(S) path now connects through a per-request undici dispatcher whose lookup is pinned to the already-validated address, so the socket reaches the exact IP that passed validation; TLS SNI and certificate validation still use the original hostname. Reported privately via KIberblick.de
First-admin bootstrap race: createUser decided the automatic first-admin promotion with a non-atomic hasAnyAdmin() check followed by a separate insert, so concurrent registrations in the zero-admin window could all be promoted. The check-then-insert now runs inside a transaction holding a Postgres advisory lock, so at most one registration wins (and it is closed entirely when INITIAL_ADMIN_* is set). Reported privately via KIberblick.de
Capability limit check-then-act race: resource-creating routes (api keys, custom dashboards, alert rules, sigma import/enable, notification channels) ran COUNT → assertWithinLimit → insert without serialization, so parallel requests could each read a count under the limit and then all insert, exceeding a configured finite cap (a quota bypass, not a tenant boundary; the OSS default has no finite limits). The count+create now runs through a shared withLimitLock helper holding a per-organization, per-capability transaction-scoped advisory lock. Reported privately via KIberblick.de
Defense-in-depth on OTLP service.name at ingestion: complementing the service-map fix, ingested service.name (logs, spans and metrics) is now run through a shared sanitizeServiceName that strips control characters (C0/DEL/C1, including null bytes) and caps the length, while preserving otherwise legitimate characters — so a raw payload can’t resurface through a sink added later. Suggested by KIberblick.de
PII masking now also covers trace span attributes: masking was wired only into log ingestion, so spans were stored with attributes verbatim — including http.request_body / http.response_body (plaintext credentials, JWTs), net.peer.ip and user agents. tracesService.ingestSpans now runs the same org/project masking rules over each span’s attributes, resourceAttributes and event/link attributes before storage, and drops (fail-closed) any span whose masking throws. Because request/response bodies are opaque stringified JSON, those are deep-masked (parse, mask password/token/email inside, re-serialize) with full redaction as a fallback. Metric attributes are not yet masked (tracked separately)
Added
Per-occurrence trace links on the error detail page: each log in an error group’s Logs tab now shows a “View Trace” action when the log carries a trace context. The error-group logs endpoint now surfaces the traceId it already loaded and previously discarded — no schema change, no migration
Copy buttons on metadata blocks: the log search expanded detail and the Log Context dialog now have a one-click copy on each metadata block, so a log’s metadata JSON can be grabbed without selecting it by hand
Breadcrumbs timeline in the log search detail: when a log carries metadata.breadcrumbs, the expanded row renders a collapsible “Breadcrumbs (N)” timeline (the same view used in the Log Context dialog) instead of leaving them buried in the raw metadata JSON
Nested metadata columns: custom metadata columns in log search now accept dot-notation paths (e.g. sdk.name) to read into nested objects. Exact top-level keys still win first, so flat keys containing dots keep resolving; object/array values render as compact JSON with the full value on hover
Fixed
Admin usage page returned 403 for orgs the admin wasn’t a member of: the metering endpoints (/usage, /usage/breakdown, /usage/storage, /usage/capabilities) gated solely on org membership, so the platform Admin → Usage page returned “Forbidden” for any org the admin didn’t personally belong to. Platform admins (is_admin) now bypass the membership check on these read endpoints; queries stay filtered by the requested organizationId, so tenant scoping is unchanged
Trace volume / latency dashboard panels were empty on ClickHouse and MongoDB: both panel fetchers read span data straight from the Postgres spans hypertable and short-circuited to an empty series when the engine wasn’t TimescaleDB. A new multi-engine reservoir.getSpanTimeseries (time-bucketed volume + true window p50/p95/p99 from raw spans — percentile_cont on TimescaleDB, quantile on ClickHouse, $percentile on MongoDB) now backs both fetchers
UI dates and numbers no longer follow the machine locale: many toLocale* calls were made with no locale, so on a non-English host they rendered localized weekdays/months (e.g. “mercoledì”) and number grouping. All user-facing date/time/number formatting is now pinned to en-US (the project convention). Swept 51 files
Error detail trend bars were invisible: the occurrence-trend bars used a percentage height whose parent column had no definite height, so it collapsed to zero. The columns now take full height with the bar anchored in a flex track (a small baseline is kept for non-zero days)
Redis memory leak: completed/failed jobs were never evicted: the BullMQ adapter defined sane removeOnComplete/removeOnFail defaults on the queue, but its add() passed those keys as undefined on every job. BullMQ merges per-job options with Object.assign, which copies the undefined keys and wiped the cleanup config, so every completed/failed job hash (and its full payload) was retained in Redis forever — growing unbounded with the high-volume ingestion jobs while the dashboard still showed 0 waiting / 0 failed. add() now omits those keys unless the caller sets them, so queue-level retention applies
Nightly SigmaHQ sync re-imported the entire catalog and auto-created alert rules: the 2:30 AM cron called the sync with no rule selection, falling into the “fetch ALL rules” path that pulled the whole catalog (~2000+ rules) and inserted them all as enabled = true, and passed autoCreateAlerts: true, inserting an alert_rules row per synced rule — so an org with 5–6 enabled rules woke up with thousands active and matching alerts. The cron now syncs only the rules the org already imported (by sigmahq_path) to refresh their detection content, and never auto-creates alert rules (a one-off cleanup of already-created rows is tracked separately)
Log Context dialog no longer overflows on wide content: a wide metadata <pre> or breadcrumb stretched the whole dialog (grid children had min-width: auto); the content now stays within the dialog and the wide block scrolls on its own axis
Sigma compound field-modifier chains were silently truncated: the matcher split a field key like CommandLine|utf16le|base64offset|contains on | but kept only the first modifier, so transform-plus-comparator chains matched incorrectly. The whole chain is now parsed and applied in order — transforms (base64, base64offset, utf16le/utf16/utf16be/wide, windash) rewrite the pattern, then the final comparator runs, following the canonical SigmaHQ model. Added cidr and numeric gt/gte/lt/lte comparators while reworking the parser
Sigma |all modifier had the wrong semantics: it was implemented as “all whitespace-split words present in any order” rather than the SigmaHQ list quantifier. |all now flips the default OR over a value list into AND (every element must match) and composes with modifier chains (e.g. cmd|base64|contains|all)
Changed
Project overview now shows an Activity Overview instead of a logs-only timeline: the project overview page replaced the “Logs Timeline (Last 24 Hours)” chart (log levels only) with the multi-signal Activity Overview, plotting logs, log errors, spans, span errors, detections and alerts over the same 24h window (toggle series from the legend). It reuses the existing custom-dashboard activity_overview fetcher via a new GET /api/v1/dashboard/activity-overview endpoint (org-membership + project-in-org scoped); no new storage or migration
Service-map p95 is now a true window percentile across all engines: the service dependency map previously reported MAX(duration_p95_ms) from the per-bucket spans continuous aggregate, which overestimates (a p95 is not derivable by combining per-bucket p95s) and was only produced on TimescaleDB. Per-service health stats now come from a new reservoir.getServiceHealthStats computed directly from raw spans over the requested window on every engine (percentile_cont on TimescaleDB, quantile(0.95) on ClickHouse, $percentile on MongoDB 7.0+), so ClickHouse and MongoDB service maps now carry real call/error/latency/p95 figures where they previously had none
Trace and session IDs in the log search detail are theme-aware: the expanded row rendered them as hardcoded light-mode pills that looked washed out in dark mode. The trace ID is now a link that opens the trace timeline (primary accent) with a separate filter button, and the session ID is a dark-safe filter button; both derive their colors from the design tokens
v1.0.2
Released
Security
1.0.2: Stream Tickets & a Frontend Bug Hunt
A frontend correctness and security release from a multi-agent frontend bug hunt, plus hardened auth for the live-streaming endpoints. The headline: single-use stream tickets keep the session token out of WebSocket/SSE URLs. One additive migration — otherwise a drop-in upgrade.
Single-use stream tickets: the session token no longer travels in WebSocket/SSE URLs, where reverse proxies log it
Webhook channel secrets are no longer rehydrated into the DOM when editing a notification channel
Global 401 handler redirects a revoked or expired session to login instead of failing silently
Stale-response race guards across log search, traces, error groups, SIEM incidents, monitors and dashboard panels
Admin pages enforce a client-side admin guard; the OIDC callback strips the token from the URL
One additive migration (049_stream_tickets) — otherwise a drop-in upgrade from 1.0.1
Show detailsHide details
A frontend correctness and security release from a comprehensive multi-agent frontend bug hunt — UI, logic, reactivity, leaks and security — plus a hardening of how the browser authenticates the live-streaming endpoints. The headline item is single-use stream tickets: the session token no longer travels in WebSocket/SSE URLs, where reverse proxies log it. One additive database migration (049_stream_tickets); otherwise a drop-in upgrade.
Security
Session token no longer placed in WebSocket/SSE URLs: browser WebSocket and EventSource cannot send an Authorization header, so the log live-tail (/api/v1/logs/ws), the SIEM events stream (/api/v1/siem/events) and the trace live-tail (/api/v1/traces/stream) previously carried the long-lived session token in the URL query string, where it is logged by proxies and servers. The client now mints a short-lived, single-use stream ticket via an authenticated POST /api/v1/stream-tickets and passes that instead. Tickets live in the relational database (not Redis, so the mechanism is portable across the BullMQ and graphile queue backends), expire in 30s and are consumed on first use. The legacy ?token= path still works for backward compatibility
Webhook channel secrets no longer rehydrated into the DOM: editing a notification channel no longer pre-fills the bearer token / basic-auth password inputs with the stored secret; the fields stay empty with a “leave blank to keep current” hint and are only sent when the user types a new value
OIDC callback strips the session token from the URL after reading it, so it no longer lingers in browser history, the referrer or logs
Admin pages enforce a client-side admin guard: several admin views (user detail, usage, organization detail) loaded and could mutate data on mount without checking the admin role; they now redirect non-admins, and the admin section layout has a guard of its own
Removed a debug console.log that leaked log message content and api-key metadata to the browser console on the error-detail page
Added
Global 401 handler: a single fetch interceptor installed at app startup clears local auth state and redirects to the login page (preserving the current path so the user lands back there after signing in) on any authenticated /api/v1 401 that is not an auth endpoint. Previously a revoked or expired session was only detected on a full dashboard remount, so a logged-out user could keep clicking around getting silent failures
POST /api/v1/stream-tickets endpoint and stream_tickets table (migration 049) backing the stream-ticket auth described above
Fixed
Stale-response races: overlapping loads triggered by fast filter/pagination changes could let an older in-flight response overwrite fresher results. Added local request-sequence guards on the log search, traces list, error groups, SIEM incidents, monitor detail/list, custom-dashboard panels and alert-preview views
API client error handling: error-branch response.json() calls are guarded so a non-JSON error body (reverse-proxy 502/HTML, empty 204) no longer throws a SyntaxError that masks the real HTTP failure (auth, admin and exceptions clients)
Locale-stable formatting: user-facing dates and numbers now use explicit en-US formatting across the status pages, members, project settings, traces, metrics, search and notification-channel views; alert-history timestamps no longer label UTC values as if they were local time
Lifecycle and memory leaks: component store subscriptions are auto-managed, a first-run shortcut-hint setTimeout is cleared on unmount, and chart instances are disposed, so navigating away no longer leaves timers, listeners or subscriptions behind
Svelte 5 reactivity and assorted UI fixes: the trace detail page reloads when navigating between traces; the api-key DSN preserves an http:// scheme for non-TLS deployments; the “View Error Group” action navigates with a param the target page actually reads; SigmaSync no longer crashes when a commit hash is absent; the toaster follows the app theme; the delete-organization confirm is disabled while in flight; PII masking rules require a regex or field names; numeric monitor inputs guard against NaN; and the audit-log resource cell no longer renders a literal escape sequence
ClickHouse: materialized-view backfills now run once instead of on every startup
Notes
Left intentionally unchanged: storing the session token in localStorage (a disputed, low-severity finding). Moving it to an httpOnly cookie would trade XSS token-theft for CSRF surface and a full auth-model overhaul without a clear net win; the high-leverage XSS defenses (CSP, output sanitization, auditing the few {@html} sites) are tracked separately
v1.0.1
Released
Security
1.0.1: Cross-Tenant Fixes & a 1.0 Bug Audit
A security and correctness release from a multi-engine bug audit of the 1.0 line. Two cross-tenant data-exposure fixes that were live in 1.0.0, plus a broad sweep of detection, ingestion, storage, alerting and frontend correctness fixes. No migrations — a drop-in upgrade.
Cross-tenant log exposure via the WebSocket live-tail closed — it now enforces the same membership check as the REST query routes (was live on 1.0.0)
Cross-tenant leak of notification-channel secrets (webhook auth tokens) closed with a membership check
PII masking fail-open fixed: separator-less card numbers are no longer stored unmasked
Storage-layer correctness fixes validated against real ClickHouse, MongoDB and TimescaleDB; CI now runs the MongoDB reservoir suite
New official Ruby SDK (the `logtide` gem) — Rack/Rails middleware, tracing and a stdlib Logger bridge; docs at /docs/sdks/ruby
No database migrations — a drop-in upgrade from 1.0.0
Show detailsHide details
A security and correctness release from a comprehensive, multi-engine bug audit of the 1.0 line. The headline items are two cross-tenant data-exposure fixes that were live in 1.0.0, alongside a broad sweep of detection, ingestion, storage, alerting and frontend correctness fixes. No database migrations — this is a drop-in upgrade. The storage-layer fixes were validated against real ClickHouse and MongoDB (and TimescaleDB), and CI now runs the MongoDB reservoir integration suite.
Security
Cross-tenant log exposure via the WebSocket live-tail (GET /api/v1/logs/ws): the handler validated only the session token and then streamed whatever projectId the client passed, never checking project/organization membership, so any authenticated user could live-tail any project’s logs by supplying its id. It now enforces the same verifyProjectAccess membership check as the REST query routes, with a regression test added. Was live on the 1.0.0 line
Cross-tenant leak of notification-channel secrets: GET /notification-channels/alert-rules/:id and /sigma-rules/:id returned the channel config verbatim — including webhook auth tokens/passwords — with no membership check. They now resolve the rule’s organization, require membership, and scope the read to that org
PII masking fail-open: maskText skipped all content rules for pure-alphanumeric strings, so a separator-less credit-card number (e.g. 4111111111111111) was stored unmasked. The all-alphanumeric early-exit is removed; only too-short strings are skipped
OIDC account-takeover: linking an external identity to an existing local account by email now requires the provider to assert the email is verified (email_verified === true), preventing takeover via an unverified IdP email; LDAP is treated as authoritative
Cross-tenant ?projectId / association gaps closed: the correlation batch-identifiers endpoint now intersects the requested project with the caller’s accessible projects (and requires read access); status-incident and scheduled-maintenance creation, organization-default channels, and alert/sigma/monitor channel associations now validate that the project/channels belong to the caller’s organization
Session invalidation: disabling a user, resetting their password, and changing a user’s admin role now invalidate the cached session (previously the cached profile stayed valid up to the cache TTL); the frontend logout now revokes the server-side session token instead of only clearing local state
Hardening: emails are stored and compared case-insensitively across registration/login/profile-update; the global CACHE_TTL override no longer clamps semantic TTLs (sessions, OIDC state, settings); the webhook envelope id is validated as a UUID; SigmaHQ category sync matches on a directory boundary; OTLP trace/span ids of invalid length are rejected
Dependency security updates, third wave (Dependabot): nodemailer bumped from ^8.0.9 to ^9.0.1 (GHSA-p6gq-j5cr-w38f, HIGH — the message-level raw option bypassed disableFileAccess/disableUrlAccess; our SMTP senders never pass raw, but the dependency is patched regardless), and transitive undici pinned to >=7.28.0 <8 (GHSA-vmh5-mc38-953g HIGH TLS bypass, GHSA-pr7r-676h-xcf6 MEDIUM cache-bypass disclosure). No vulnerable version remains in the lockfile
Fixed
ClickHouse “Query with id … is already running” under concurrent queries: the request-context propagation derived the ClickHouse query_id deterministically from requestId + operation, so two same-operation queries running concurrently within one request reused the same id and ClickHouse rejected the second. The query_id now keeps the request id + operation as a readable prefix and appends a random suffix so every query is unique
Sigma condition operator precedence: parseExpression folded AND/OR strictly left-to-right, so a or b and c evaluated as (a or b) and c. AND now binds tighter than OR, matching the Sigma spec
Infinite loop on ingestion: a custom identifier pattern that can match the empty string (e.g. \d*) no longer hangs the worker (zero-width-match guard plus a forced global flag)
Trace summary races: upsertTrace is now race-free on TimescaleDB (single atomic INSERT … ON CONFLICT) and ClickHouse (the summary is recomputed from the spans table instead of read-modify-write), so concurrent span batches no longer lose span counts
ClickHouse: added the missing session_id column (it was written and filtered on but did not exist), parse zone-less datetimes as UTC, and report the true latest value in the metrics overview (was the average) with NaN guards
MongoDB: deleteMetricsByTimeRange only deletes the matched metrics’ exemplars (was all exemplars in the window); the metrics overview sorts before $last; metadata distinct no longer drops numeric/boolean values; ingestReturning returns the rows that did land on a partial bulk-write failure
Rate-of-change alerts: the baseline now applies the rule’s metadata filters (previously only the current rate did, deflating the deviation ratio), and a minBaselineValue of 0 is honored instead of defaulting to 10
Dashboards: personal dashboards are scoped to their creator (no longer readable/editable by any org member); metric_stat sums across buckets for sum/count aggregations; top-error percentages are computed against the true total; the capability usage page renders a zero limit as fully restricted, not unlimited
Pagination/counts: admin organization/project search counts apply the search filter; correlation lookups report the true total; the ClickHouse trace error-rate uses a consistent denominator; getTopServices honors the to bound
Live tail: SSE polling bypasses the 60s query cache and guards against overlapping polls; the live-tail WebSocket URL works behind a reverse proxy; the traces and search live tails no longer corrupt pagination or index-keyed row state
Infra/correctness: BullMQ and graphile queue retry-attempt defaults are aligned; webhook deliveries can only be replayed from the terminal dead state (no double delivery); the audit flush buffer is bounded on persistent DB failure; alert-notification jobs are deduplicated by history id; admin org-role changes are restricted consistently with member removal; tenant-table updates in the log-pipeline and service-dependency queries are project-scoped
Added
Official Ruby SDK — the logtide gem for Ruby 3.1+, implementing the LogTide SDK spec v1.0 with leveled logging, structured exception capture, the Hub/Scope model, spans + OTLP export, W3C traceparent propagation, Rack middleware, a Rails Railtie, and a stdlib Logger bridge — standard library only, no runtime dependencies. Docs at /docs/sdks/ruby
Changed
The reservoir storage abstraction gains getTraceServices (distinct trace services with no result cap), implemented on all three engines and used by the traces service, replacing the previous 10k-trace paging
CI now provisions a MongoDB service for the reservoir integration suite, so all three storage engines are exercised on every run
First beta of the 1.0 line. Tenant data isolation audit, per-org metering with a capability/quota system, typed lifecycle hooks, a hardened outbound webhook dispatcher, a structured audit log primitive, and fail-closed PII masking.
Tenant data isolation audit across every backend data-access path, with a CI tripwire and isolation test suite (#228, #219)
Typed lifecycle hooks for ingestion, query, alert evaluation and webhook dispatch (#216)
Outbound webhook delivery: HMAC signing, retry with backoff, dead-letter queue and SSRF protection (#218)
Audit log primitive: typed actions, actor types, outcomes and per-org retention (#217)
PII masking is now fail-closed at ingestion — records that fail masking are rejected, never stored unmasked
Show detailsHide details
First beta of the 1.0 line. The headline work since 0.9.7 is the tenant data isolation audit that hardens every backend data-access path before the stable cut, and the metering + capability system pair that gives every organization usage measurement, feature gates and enforceable limits/quotas (ingestion, spans, storage) — the foundation for plan tiers without changing OSS behavior. A typed lifecycle hooks surface lands alongside it, letting operators observe, mutate or reject ingestion, query, alert-evaluation and webhook-dispatch without forking. A reusable outbound webhook delivery system also lands — HMAC signing, retry with backoff, a dead-letter queue and centralized SSRF protection — onto which every existing webhook sender is migrated, closing three unguarded fetch paths in the process.
Security
PII masking is now fail-closed at ingestion: previously, if the masking step threw, the batch was stored unmasked with only a console.warn. maskLogBatch now reports per-record failures and the ingestion service rejects exactly those records before the reservoir write (the whole batch if rule compilation itself fails). Rejected records are reported back to the client in a new optional rejected: [{ index, reason: 'pii_masking_failed' }] field on the ingest response (omitted when empty, so existing clients are unaffected) and as partialSuccess.rejectedLogRecords on the OTLP logs endpoint. No unmasked data can reach any storage engine through ingestLogs
Cross-tenant log reads via unvalidated ?projectId on the logs query API (#228, audit #219): all 10 endpoints in the query module accepted a ?projectId= parameter and queried with it directly, without verifying it belonged to the authenticated API key’s bound project. Fixed with a shared resolveQueryProjectId guard that returns 403 when the requested projectId does not match the key’s project, locked by a dedicated isolation test
Cross-tenant trace/span reads via the same pattern on the traces query API (#228): the identical flaw existed on 8 trace/span read endpoints, fixed with the same guard and locked by an isolation test. The metrics query API was already safe
Application-layer scoping gaps on tenant-table queries (defense in depth, #228): a sweep of every tenant-table access path closed several queries missing an organization_id / project_id filter (PII-masking, SIEM, exceptions, correlation). Session-auth routes now Zod-validate organizationId as a uuid instead of a raw cast
Dependency security updates ahead of the 1.0 cut: vitest / @vitest/coverage-v8 to 3.2.6, and transitive esbuild (≥ 0.28.1) and shell-quote (≥ 1.8.4) pins bumped via the root pnpm overrides. No vulnerable version remains in the lockfile
Added
Capability system: per-organization feature gates, static limits and usage quotas (#214): a typed capability registry is the single source of truth for 12 initial capabilities across three kinds — boolean gates (auth.sso, detection.advanced, audit.enabled, isolation.dedicated), static numeric limits (alerts.max_rules, notifications.max_channels, apikeys.max, audit.retention_days) and metered usage quotas (ingestion.max_bytes_monthly, ingestion.max_events_monthly, storage.max_bytes, tracing.max_spans_monthly). All defaults are OSS-permissive (booleans enabled, limits/quotas unlimited), so self-hosted behavior is unchanged until an operator sets a cap. Per-org overrides live in a new organization_entitlements table; a cache-backed resolver merges rows over registry defaults, fails open on DB errors, and is swappable so a hosted distribution can source entitlements from subscription state without patching core. Enforcement: assertCapability (403), assertWithinLimit (403) and assertWithinUsageQuota (429, hard-blocks log and OTLP span ingestion). Read API GET /api/v1/capabilities returns the merged set for UI gating; admin endpoints manage per-org overrides, surfaced in an “Entitlements” card on the admin organization page
Resource usage metering per organization and project (#212): a storage-agnostic way to record and aggregate consumption, surfaced as a “Usage” section in the dashboard. A new metering_events TimescaleDB hypertable is fed by a non-blocking, loss-tolerant in-process recorder that buffers metering.record(...) calls and batch-inserts on a size threshold, flush interval, and graceful shutdown — dropping under back-pressure rather than blocking the request path. Log ingestion records logs.ingested.bytes and logs.ingested.events fire-and-forget after the reservoir write (OTLP ingestion is metered too). Read APIs GET /api/v1/usage and GET /api/v1/usage/breakdown answer “what is being ingested” by type, project, service and log level. Tunable via METERING_ENABLED, METERING_FLUSH_INTERVAL_MS and METERING_FLUSH_MAX_BUFFER
Span and storage metering recording sites (#212 follow-up): span ingestion now records spans.ingested, activating the tracing.max_spans_monthly quota end-to-end. A daily StorageSnapshotJob records a per-(organization, project) storage.snapshot estimating stored bytes as the logical bytes ingested within the org’s retention window — engine-agnostic by design — which activates storage.max_bytes. Usage pages gain a “Current storage (estimated)” stat and a daily storage trend
Capability usage vs plan limits on the usage page: a “Plan limits” section shows current consumption against each configured capability limit as a color-coded progress bar (green < 80%, amber 80–99%, red ≥ 100%), backed by a new GET /api/v1/usage/capabilities endpoint that joins live usage to the configured cap for every measurable capability. A null limit renders as “Unlimited”, so OSS defaults read correctly
Audit log primitive: typed actions, actor types, outcomes and per-org retention (#217): the audit log grows from a free-string event sink into a structured security primitive. A canonical action registry (70 actions across 13 families like org.*, apikey.*, rule.*, auth.*, data.*) is the single source of truth — actions are a TypeScript string-literal union, so typos fail to compile. A new auditLogService.record() API reads organization, actor (user/apiKey/system), IP and user agent from the request context, so callsites only state the action and target; it supports atomic recording inside a transaction, awaited fire-and-safe inserts that never fail the request, and a flush buffer for high-volume access logging. All 57 legacy callsites were converted; new coverage includes failed local logins (auth.login_failed, previously not recorded at all). Per-org audit_retention_days (1–3650, NULL = keep forever) is editable from the admin org page and enforced by the daily retention job. The audit page gains actor-type, outcome and time-range filters
Lifecycle hooks at ingestion, query, alert evaluation and webhook dispatch (#216): a small, typed set of named extension points (beforeIngest, beforeQuery, beforeAlertEvaluation, beforeWebhookDispatch), no-op in OSS (nothing registered by default, a hasHandlers guard keeps the hot paths at zero overhead). Handlers run sequentially, receive a typed per-phase context with documented mutable fields, and may abort by throwing HookRejectionError(code, message, statusCode); unexpected hook errors fail closed. Operators register handlers without forking via HOOKS_MODULES. Contract documented in the new Lifecycle Hooks guide
after- lifecycle hook phases* (#216 follow-up): afterIngest (batch counts and rejection reasons), afterAlertTriggered (post-persist trigger facts) and afterWebhookDispatch (per delivery-attempt result). Fire-and-forget: handler errors are logged and never block or mutate the operation; contexts are frozen read-only snapshots
Generic outbound webhook delivery infrastructure (#218): a reusable dispatcher that centralizes every outbound HTTP delivery behind one well-tested module. Capabilities: optional HMAC-SHA256 signing (X-Logtide-Signature: t=<unix>,v1=<hex>, timing-safe to verify); retry with exponential backoff (1s, 5s, 25s, 2m, 10m, transient-only); a dead-letter queue modeled as webhook_deliveries.status='dead', listable and replayable from the dashboard; SSRF protection by reusing the safeFetch guard; per-organization concurrency limiting; and a bounded delivery log exposed via an org-scoped API and a “Webhook Deliveries” page under Settings. All five existing webhook senders were migrated onto it — the error/monitor/incident paths previously used a bare fetch with no SSRF guard, closing three sibling gaps. Documented in the new Webhooks guide
Request context propagation across HTTP, jobs and the DB layer (#222, closes #213): a new AsyncLocalStorage-backed RequestContext primitive in @logtide/shared/context. A Fastify plugin establishes the context right after auth resolves; BullMQ producers piggy-back _ctx on payloads and consumers wrap processors in context.run / runAsSystem; cron callbacks run under runAsSystem. The pg.Pool is patched to prepend a /* req=... */ SQL comment so requests are traceable in slow-query logs, and each reservoir engine injects the same correlation in its native form (Timescale SQL comment, ClickHouse query_id + log_comment, MongoDB $comment)
Tenant isolation audit, test suite and CI tripwires (#228, #219): a living audit document, a dedicated isolation test suite (the createIsolatedTenants fixture plus per-area tests for query, traces, crud, api-key auth, audit-log and metering) that runs in CI, a static tripwire (check:tenant-scoping) that flags Kysely queries on tenant tables lacking org/project scoping and fails the CI typecheck on new unscoped sites, an opt-in runtime Kysely guard (TENANT_GUARD=1), and a PR template carrying a tenant-safety checklist. The codified model: organization is a hard boundary for everyone, API keys are project-scoped, session users are org-wide by current policy
Capability enforcement completed across resource creation (#214 follow-up): the previously defined notifications.max_channels and apikeys.max limits are now enforced at channel/key creation (API keys counted org-wide), and two new limit keys land with enforcement: sigma.max_active_rules and dashboards.max_custom. All defaults stay unlimited for self-hosted OSS
Ingestion health visibility for admins: a new GET /api/v1/admin/stats/ingestion-health endpoint aggregates the last 24h of new ingestion health counters (ingestion.pii_rejected, ingestion.detection_enqueue_failed, and more) plus SIEM enrichment availability, surfaced as an “Ingestion health (24h)” card that highlights in red when failures occurred. Sigma-detection and exception-parsing job enqueues now retry once and record a counter on failure instead of dropping silently
Scheduled email digest reports — groundwork, disabled (#209, part of #154): the foundation for periodic email summaries of log activity. Intentionally disabled in this beta — the worker leaves the scheduler unregistered and no digests are sent. The service, schema and tests are merged so the remaining scope can be finished. What landed: digest_configs / digest_recipients tables, an ICronRegistry abstraction with native-cron implementations for both BullMQ and Graphile, and a generator that computes log volume from the hourly-stats continuous aggregate
Test debt paid down: six backend coverage exclusions are gone, the 12 previously untested @logtide/shared Zod schemas gain dedicated tests, CI now runs the shared and frontend unit suites alongside the backend, and frontend component testing is enabled (Svelte 5 + testing-library). A dedicated Playwright journey covers the full traces flow (list, filters, waterfall, span panel and trace/log correlation)
Migrations 044–048: digest_email_reports (044), metering_events (045), organization_entitlements (046), webhook_deliveries + webhook_delivery_attempts (047), and additive actor_type/actor_id/outcome columns on the audit_log hypertable (048)
Changed
BREAKING: unified webhook event envelope (#218 follow-up): every outbound webhook delivery now serializes to one envelope { id: "evt_<uuid>", type, version: 1, occurredAt, organizationId, projectId, data } instead of four bespoke payload shapes. Event types are alert.triggered, incident.created, error.detected, monitor.status_changed and channel.test; the per-type data payloads keep their previous snake_case fields minus event_type/timestamp. Envelope and per-type Zod schemas ship in @logtide/shared (webhookEnvelopeSchema, parseWebhookEvent); deliveries carry a new X-Logtide-Event-Version: 1 header. See the Webhooks guide for the full envelope reference
OTLP log metadata shape: resource attributes now land under metadata.resource instead of being spread flat (log-record attributes keep the flat namespace and win collisions there; nothing is lost). Structured log bodies (kvlist/array) keep their decoded structure under metadata['otel.body'] alongside the stringified message. Logs ingested before this change keep their old shape
Reservoir log query params now require projectId (#228): projectId was optional on the reservoir query/count/aggregate params, which is what allowed an unscoped log read to compile. It is now required, with an explicit GLOBAL_SCOPE sentinel for the handful of intentional platform-wide reads
Fixed
Sigma search by MITRE technique/tactic/tag always 500ed: the queries compared TEXT[] columns with a ::jsonb cast and failed on every request since they shipped; they now use proper text-array containment and are covered by regression tests
Migration prefix collision that could break production migrate (#229): two migration pairs shared a numeric prefix, which Kysely’s position-by-position validation could reject. Fixed by renumbering the files plus an idempotent pre-migration repair that renames the matching kysely_migration rows on already-applied databases; a new CI guard rejects future duplicate prefixes. Verified on both a fresh schema and a database seeded into the pre-fix state
DELETE /api/v1/sigma/rules/:id and custom-dashboard update/delete returned 500 instead of 404 for a missing or cross-org id (#229): these now map to a proper 404. None leaked or mutated another tenant’s data — the scoping already prevented the operation; this only corrects the HTTP status
Silent failures made visible across admin and monitoring UI: the admin version check no longer claims “up to date” when GitHub is unreachable; the monitoring page no longer swallows incidents/maintenance load errors; GeoLite2/IPsum unavailability warns once per outage instead of spamming every lookup
API-key last_used write amplification on hot keys (#222): every authenticated request updated api_keys.last_used, producing ~100 updates/sec on a single row under load. The update is now debounced to at most once per 60s per key
v0.9.7
Released
Fix
SSRF Fix in Alert/Sigma Webhook Delivery
Closes a sibling-gap left by the 0.9.6 SSRF hardening: the alert/Sigma webhook delivery path now routes through the centralized safeFetch guard instead of the bypassable inline filter (GHSA-7v53-pw6r-99vj, CWE-918).
SSRF fix: alert/Sigma webhook delivery now uses the centralized safeFetch guard (GHSA-7v53-pw6r-99vj, CWE-918)
DNS resolution + per-redirect-hop revalidation + full IPv4/IPv6 private/reserved range coverage on webhook delivery
Read-back oracle closed: blocked targets are rejected before the response body is read
MONITOR_ALLOW_PRIVATE_TARGETS still lets self-hosted deployments target internal endpoints
Show detailsHide details
Security
SSRF in alert/Sigma webhook delivery via the legacy delivery path (authenticated) (GHSA-7v53-pw6r-99vj, CWE-918): the 0.9.6 SSRF hardening added the centralized utils/ssrf-guard.ts guard and wired it into the HTTP/TCP monitors and the WebhookProvider, but the actual alert/Sigma webhook delivery path was left on the old inline filter. sendWebhookNotification in queue/jobs/alert-notification.ts (reached by the alert-notifications BullMQ worker for threshold, rate-of-change and Sigma-rule alerts) still ran a bare fetch(webhook_url, …) guarded only by a string-based isPrivateIP(). That check was bypassable: any non-dotted-quad hostname returned false (no DNS resolution), so a domain whose A record points at 127.0.0.1 / 169.254.169.254 / an internal host passed the filter and the resolved internal address was then connected; the bare fetch used the default redirect: 'follow', so a public host that 302s to an internal URL was followed straight there; and CGNAT (100.64.0.0/10), IPv6, IPv4-mapped IPv6, 0.0.0.0/8 and 198.18.0.0/15 were not covered. An authenticated org owner/admin who can create a webhook notification channel could therefore use the backend as a blind SSRF probe against internal services and cloud metadata, with a partial read-back oracle since a non-2xx internal response body was spliced into the alert-history error message. The guarded “Test” button already blocked the same URLs, confirming this as an incomplete-fix sibling-gap. Fix: sendWebhookNotification now routes delivery through safeFetch(url, init, { allowPrivate: config.MONITOR_ALLOW_PRIVATE_TARGETS }), exactly as WebhookProvider does (DNS resolution + per-redirect-hop revalidation + full IPv4/IPv6 private/reserved range coverage), mapping SsrfBlockedError to the existing “private/internal addresses are not allowed” error. The inline isPrivateIP/BLOCKED_HOSTS filter and the bare fetch are removed, and blocked targets are now rejected before the response body is read (closing the read-back oracle). The MONITOR_ALLOW_PRIVATE_TARGETS opt-in still lets self-hosted deployments target internal endpoints. Reported by tonghuaroot
Frontend now runs as a full SPA on adapter-node (kills hydration bugs), SSRF guard on monitors and webhooks, cross-tenant projectId validation on four routes, OIDC iss-parameter fix for Authelia, and an infinite-skeleton fix on search/traces/metrics.
Frontend converted to a full SPA (ssr=false root cascade), removing 22 per-route ssr=false workarounds and a whole class of hydration bugs
Security: SSRF guard on uptime monitors and webhooks, denied by default (MONITOR_ALLOW_PRIVATE_TARGETS to opt in)
Security: cross-tenant projectId validation enforced on alerts/preview, alerts, monitors and sourcemaps routes
OIDC login fixed for issuer-identifying providers (RFC 9207 iss param) like Authelia (#233, #234)
Fixed infinite skeleton spinner on search/traces/metrics when no project had a data-availability flag
Show detailsHide details
Changed
Frontend now runs as a full SPA on adapter-node instead of doing SSR with client hydration. A single export const ssr = false in the new root +layout.ts cascades to every route, so the server only ships the empty app shell and the client renders from scratch. Eliminates the entire class of hydration mismatch bugs that had been accumulating across login, register, invite, public status page, the /auth/callback page and the various dashboard subpages, each previously patched with its own per-route ssr = false. Server load functions and the Node runtime are untouched (the adapter, API proxying, env vars, BullMQ etc. keep working exactly as before); there are no +page.server.ts / +layout.server.ts files in the repo today so nothing had to change semantically on the server side. UX tradeoff: the public status page at /status/[orgSlug]/[projectSlug] now flashes the empty shell for one paint before the JS hydrates, which is acceptable for an authenticated-by-default product but should be revisited if SEO on the status page becomes a goal (a single-line override export const ssr = true in that page’s +page.ts puts it back on SSR without affecting anything else)
Removed 22 redundant route-level ssr = false declarations (dashboard/+layout.ts, dashboard/admin/+layout.ts and 20 +page.ts files across landing, login, register, onboarding, invite, status, auth callback and the dashboard search / metrics / alerts / monitoring / projects / sessions / settings subtrees) that had been added one by one as each page hit a hydration bug. The new root-level cascade makes them all dead config; deleting them removes the temptation to copy the pattern into new routes
Fixed
Infinite skeleton spinner on /dashboard/search, /dashboard/traces and /dashboard/metrics when no project in the org had its data-availability flag set, typically right after a user deleted the only projects that had been ingesting. The filter logic at search/+page.svelte:441-444 (and the identical pattern at traces/+page.svelte:142-145 and metrics/+page.svelte:90-93) read const logsProjectIds = availability?.logs and then branched on logsProjectIds ? filter : fallback. When getProjectDataAvailability legitimately returned { logs: [] } the empty array took the truthy branch ([] is truthy in JS) and [].includes(p.id) excluded every project, so the displayed project list was empty, loadLogs() never fired, and hasLoadedOnce stayed false so the SkeletonTable rendered forever. Fix: guard the truthy branch with logsProjectIds && logsProjectIds.length > 0 so an empty availability response falls back to “show all projects” exactly like the API-failure path (.catch(() => null)) already does. The 0.9.4 backend optimization that introduced the cached availability flags is unaffected; this is purely a frontend null-vs-empty conflation. Logs of already-hard-deleted projects on the TimescaleDB engine remain unrecoverable due to ON DELETE CASCADE on logs.project_id (tracked separately under the soft-delete projects epic)
OIDC login failed against issuer-identifying providers (e.g. Authelia) because the iss callback parameter was dropped (#233, #234): the OIDC callback handler extracted only code and state from the provider redirect and rebuilt the callback URL from just those two, discarding everything else. Providers implementing RFC 9207 (OAuth 2.0 Authorization Server Issuer Identification) append iss to the redirect and openid-client’s authorizationCodeGrant() validates it, so the token exchange failed with “issuer parameter missing” and login never completed. The Fastify callback route now forwards the full request.query through handleOidcCallback into the provider, which replays every parameter onto the callback URL handed to the token exchange (so iss, session_state, etc. survive); the required code/state are always re-asserted from the validated values. Duplicated/array-valued query params are collapsed to a single value with searchParams.set instead of being appended, since OIDC authorization-response params are single-valued per RFC 6749 / RFC 9207, avoiding a malformed URL with duplicate iss/code reaching authorizationCodeGrant. Covered by new tests across the route, service and provider layers, including the array-collapse and undefined-param branches
Security
Cross-tenant project access via unvalidated projectId (authenticated): several routes accepted both an organizationId and a projectId, verified the caller was a member of the organization, but never verified that the supplied project actually belonged to that organization. Since project UUIDs are normal identifiers that appear in dashboard URLs and client API calls, a user could pair their own organizationId (membership check passes) with a victim’s known projectId and reach another tenant’s data. Confirmed on four handlers: POST /api/v1/alerts/preview returned sampleLogs (time, service, level, message, trace ID) from the foreign project; POST /api/v1/alerts and POST /api/v1/monitors let a rule/monitor be scoped to a foreign project (the monitor case also surfaces on the victim’s public status page, which renders by project_id); and GET/DELETE /api/v1/sourcemaps let a member list or delete another tenant’s source maps. Fix: a shared projectsService.projectBelongsToOrg(projectId, organizationId) helper (single projects lookup filtered on both id and organization_id) is now enforced right after the existing membership check on each of those routes, returning 403 when the project is foreign. The alert/monitor update paths don’t accept a projectId and the custom-dashboards panel pipeline already had an equivalent ensureProjectInOrg guard at its choke point, so no change was needed there. Regression tests cover each handler
Server-side request forgery (SSRF) and internal port scanning via monitors and webhooks (authenticated): HTTP/TCP uptime monitors and webhook delivery executed user-supplied targets from the backend’s network with no meaningful destination validation. Monitor creation only checked that an HTTP target started with http(s):// and that a TCP target contained :; checker.ts then called fetch(target, { redirect: 'follow' }) and createConnection({ host, port }), so a registered user could point a monitor at http://169.254.169.254/…, http://127.0.0.1, 10.0.0.0/8, etc. and use the sanitized up/down result and timing to probe internal services. The webhook provider had only literal-string private-IP filtering (no DNS resolution, incomplete IPv6, followed redirects), leaving DNS- and redirect-based bypasses open. Fix: a centralized outbound guard (utils/ssrf-guard.ts) resolves hostnames and rejects loopback, private, link-local (incl. 169.254.169.254 cloud metadata), CGNAT (100.64.0.0/10), multicast and reserved IPv4/IPv6 ranges (including IPv4-mapped IPv6 and ULA/fc00::/7, link-local fe80::/10). TCP checks resolve-then-pin the socket to the validated address (closing DNS-rebinding between validation and connect); HTTP checks and webhook delivery follow redirects manually and revalidate every hop instead of redirect: 'follow'. The guard runs both at monitor create/update time (immediate 400 feedback) and at execution time (authoritative, returns a blocked result). Private/internal targets are denied by default; self-hosted deployments that legitimately monitor internal services can opt back in with MONITOR_ALLOW_PRIVATE_TARGETS=true, which also governs webhook delivery. Note: HTTPS does not yet pin the connected address against a custom dispatcher, so a narrow DNS-rebinding window remains for HTTP monitors (tracked for a follow-up); the reported direct-target and redirect-to-internal vectors are closed
Metadata filters now work on ClickHouse and MongoDB, error notifications are throttled per group to stop email storms, the notification-channels defaults endpoint accepts monitoring, plus 19 Dependabot advisories resolved.
Metadata filters now translated on ClickHouse and MongoDB, not just TimescaleDB (#226, #224)
Error notifications throttled per error group via a race-safe cooldown (ERROR_NOTIFICATION_COOLDOWN_MINUTES, default 15)
notification-channels defaults endpoint now accepts the monitoring event type
Migration 043 adds last_notified_at to error_groups
Metadata filters were silently ignored on ClickHouse and MongoDB (#226, issue #224): the metadata filter operators (equals, not_equals, in, not_in, exists, not_exists, contains) were only translated by the TimescaleDB query builder. The ClickHouse and MongoDB query translators never read params.metadataFilters, so any log search or alert rule that relied on a metadata.* filter came back unfiltered on those engines (the filter appeared to do nothing). ClickHouse now translates each filter to a predicate over the JSON metadata column using JSONExtractString paired with JSONHas to distinguish a missing key from an empty string (not_in/not_equals split on whether the key is present, contains uses positionCaseInsensitive). MongoDB builds one clause per filter keyed on metadata.<key>, all wrapped in $and so repeated filters on the same key don’t overwrite each other, with include_missing controlling whether not_equals/not_in also match documents where the field is absent ($exists). Covered by new per-engine translator tests
Error notifications spammed one email per occurrence: processErrorNotification sent an in-app notification, email and webhook for every exception occurrence, suppressed only when the error group’s status was ignored. A high-frequency client error (e.g. a Svelte effect_update_depth_exceeded loop firing thousands of times) produced one exception row per occurrence and therefore thousands of identical alert emails. The job now throttles per error group: it atomically claims a notification slot via UPDATE error_groups SET last_notified_at = now() WHERE status != 'ignored' AND (last_notified_at IS NULL OR last_notified_at <= cutoff) RETURNING id, so only the first occurrence inside the cooldown window notifies and the rest are skipped. The conditional UPDATE is race-safe (concurrent jobs serialize on the row lock and re-evaluate the predicate against the freshly written timestamp), so even a burst of thousands collapses to a single notification per window. Cooldown is configurable via ERROR_NOTIFICATION_COOLDOWN_MINUTES (default 15, set 0 to notify on every occurrence). Occurrence counts on the error group and in-app dashboards are unaffected
monitoring rejected by the notification-channels defaults endpoint: PUT/GET /api/v1/notification-channels/defaults/:eventType validated :eventType against a local Zod enum of ['alert', 'sigma', 'incident', 'error'] that was missing monitoring, even though the shared NotificationEventType type, the DB organization_default_channels constraint (migration 037) and the service all support it. Setting a default monitoring channel returned 400 Validation error (“received ‘monitoring’”). Added monitoring to the route enum so the five event types are consistent across the stack
Added
Migration 043 error_notification_throttle: adds a nullable last_notified_at TIMESTAMPTZ column to error_groups, used as the per-group notification cooldown anchor (see the error-notification throttle fix above)
Security
Resolved 19 Dependabot advisories (8 high, 11 moderate) by bumping direct dependencies and pinning patched versions through the root pnpm overrides. protobufjs → 7.6.1 (kept on the 7.x line via >=7.5.8 <8; covers code injection, prototype-pollution gadget, unbounded-recursion DoS, unsafe option paths, crafted-field DoS, overlong UTF-8 in @protobufjs/utf8 >=1.1.1). kysely → 0.28.17 (bounded >=0.28.17 <0.29; JSON-path traversal injection in JSONPathBuilder.key()/.at()). svelte → 5.55.9 (>=5.55.7; SSR XSS via spread attributes and promise serialization, DOM-clobbering XSS, <svelte:element> ReDoS). @sveltejs/kit → 2.61.1 (>=2.60.1; query.batch cross-talk). fast-uri → 3.1.2 (path traversal + host confusion via percent-encoded segments). qs → 6.15.2 (DoS in qs.stringify), devalue → 5.8.1 (sparse-array DoS), brace-expansion → 5.0.6 (numeric-range DoS), ws → 8.21.0 (uninitialized memory disclosure). protobufjs and kysely were deliberately held on their current major/minor lines (their “latest” is a breaking jump) while still landing on the patched release
Data-availability endpoint now reads cached flags on projects (6s+ → <50ms on ClickHouse), activity_overview panel works across all engines, 14 dashboard pages get proper titles, plus uuid 14 and fast-xml-parser 5.7.3 security bumps.
GET /api/v1/projects/data-availability rewritten to read cached has_X_at flags — 6s+ → <50ms on production ClickHouse
Boot-time one-shot backfill for the new project flags, throttled and gated by system_settings
activity_overview panel logs/log_errors series now work on ClickHouse and MongoDB via reservoir.aggregate
14 dashboard pages now set proper <title> instead of falling back to generic 'LogTide'
Security: uuid 14.0.0 (CVE-2026-41907) and fast-xml-parser 5.7.3 (CVE-2026-41650) bumps
Show detailsHide details
Changed
GET /api/v1/projects/data-availability rewritten to read from cached flags on projects instead of fanning out N*3 existence queries to the reservoir per call. In production (ClickHouse, ~70M rows) the old path scanned count(*) and LIMIT 1 probes across logs/traces/metrics for every project in the org, taking 6s+ on non-trivial orgs. The new path is a single SELECT id, has_logs_at, has_traces_at, has_metrics_at FROM projects WHERE organization_id = $1 plus an in-memory staleness filter against organizations.retention_days, returning in <50ms regardless of ingest volume. The response shape is unchanged ({ logs: string[], traces: string[], metrics: string[] })
Ingest paths mark data-availability flags fire-and-forget: after a successful batch in logs / traces / metrics ingest, projectsService.markHasData(projectId, kind) updates projects.has_X_at. A module-scoped in-memory debounce (5 min per (projectId, kind)) prevents UPDATE spam on hot projects: at most one UPDATE per 5 min per pod per project per kind. Failures are logged and swallowed so ingest is never impacted; the boot-time backfill is the safety net
Added
Migration 042 project_data_availability: adds nullable has_logs_at, has_traces_at, has_metrics_atTIMESTAMPTZ columns to projects. No backfill in SQL (handled at runtime, see below)
One-shot backfill at server boot: runDataAvailabilityBackfill() runs fire-and-forget after app.listen, guarded by a data_availability.backfilled row in system_settings so subsequent boots are a no-op. It selects every project with all three flags still NULL and, for each, runs three LIMIT 1 probes (logs / traces / metrics) via the reservoir in parallel. Throttled in batches of 10 with a 50ms pause to avoid hammering ClickHouse/Timescale/Mongo at boot. On a deployment with 1000 projects this takes ~6s on ClickHouse or MongoDB, up to ~30s on TimescaleDB when most projects are empty (chunk scan confirming zero rows). Force a re-backfill by deleting the safety row
Multi-engine staleness rule: a project is reported as “has data” only if has_X_at >= now() - organizations.retention_days. Handles the “data aged out by retention” case without needing a background worker. The same logic works identically on TimescaleDB, ClickHouse and MongoDB deployments because the flag lives in Postgres and the reservoir only backs the ingest and backfill paths
Fixed
activity_overview dashboard panel showed empty logs / log_errors series on ClickHouse and MongoDB: the fetcher in panel-data-service.ts gated both the logs and the spans queries behind reservoir.getEngineType() === 'timescale', so on non-Timescale engines those branches were skipped entirely and the panel rendered with all four log/span counters flat at zero (only detections and alerts, which read from Postgres operational tables, kept working). The logs path now branches: TimescaleDB still uses the logs_hourly_stats / logs_daily_stats continuous aggregate with the raw logs fallback, while ClickHouse and MongoDB go through reservoir.aggregate({ interval: '1h' | '1d' }) (same dual pattern already used by baseline-calculator). Bucket boundaries from toStartOfHour / $dateTrunc align with the panel’s UTC buildBucketTimes, so the keys collide cleanly. logs sums total per bucket, log_errors sums byLevel.error + byLevel.critical. Spans / span_errors stay TimescaleDB-only because IReservoir has no aggregateSpans() primitive, matching the existing convention in trace_latency and trace_volume
Missing <title> on 14 dashboard pages: every tab opened on /dashboard, /dashboard/admin/{organizations,projects,users,settings} (list and detail), /dashboard/monitoring (list and detail) and /dashboard/projects/[id]/{alerts,overview,performance,settings} showed the generic fallback “LogTide” from app.html because no <svelte:head><title>...</title></svelte:head> was set on the page. They now follow the existing Title - LogTide convention used by the rest of the app, with dynamic titles where the entity is already in scope: {$activeDashboard.name} for the custom dashboards root, {org.name} / {project.name} / {user.name ?? user.email} / {monitor.name} for the four [id] pages (each with a sensible fallback while the loader resolves). The two pure-redirect pages (/dashboard/projects/[id] and /dashboard/settings, which goto their default sub-route on mount) intentionally keep no title since the destination sets one immediately
Security
Bump uuid to 14.0.0 (GHSA-w5hq-g745-h8pq, CVE-2026-41907, MODERATE): v3, v5 and v6 accepted a caller-provided buf + offset but did not validate bounds, so a small buffer or an out-of-range offset produced silent partial writes instead of throwing RangeError like v1/v4/v7 do. uuid is pulled in transitively by bullmq and ldapts (production) and bson/ioredis (dev only); both production consumers resolve to their ESM entry points from our "type": "module" backend, so the major bump from ^11.1.0 is safe (uuid 12+ dropped CommonJS, but we never load the CJS path). Added "uuid": ">=14.0.0" to the root pnpm overrides; resolves to 14.0.0. Engines requirement of Node 20+ is already satisfied by engines.node: ">=20.0.0"
Bump fast-xml-parser to 5.7.3 (GHSA-gh4j-gqv2-49f6, CVE-2026-41650, MODERATE): XMLBuilder did not escape the --> sequence in comment bodies or the ]]> sequence in CDATA sections, so user-controlled data flowing into either could break out and inject sibling XML nodes (XSS in browser-rendered XML, SOAP injection, RSS/SVG payloads, etc.). The fix lands in 5.7.0; we use it transitively only via @aws-sdk/xml-builder pulled in by @types/nodemailer (dev), but bumping the override from >=5.5.7 to >=5.7.0 clears the lockfile advisory. Resolves to 5.7.3
v0.9.3
Released
Feature
Traces Live Tail, Dashboard Panels & Async Ingestion Buffer
Traces gain live tail (SSE), row expand, keyboard shortcuts and export; monitoring reorganized into tabs; user settings promoted to a full page; new trace_volume and activity_overview dashboard panels; opt-in ReservoirBuffered async ingestion with in-memory and Redis Streams transports.
Traces live tail via SSE, inline span expand, j/k/enter/r keyboard shortcuts and JSON/CSV export
Monitoring page split into Monitors / Incidents / Maintenance / Status page tabs with summary cards
User settings promoted from dialog to full page at /dashboard/account
New trace_volume and activity_overview dashboard panels using continuous aggregates with raw fallback
Opt-in ReservoirBuffered async ingestion: in-memory (signal-wakeup) and Redis Streams transports with DLQ, circuit breaker and Prometheus metrics
Show detailsHide details
Added
Traces live tail (SSE): new GET /api/v1/traces/stream Server-Sent Events endpoint polls the traces table once a second and emits new trace_ids as they appear, filtered by projectId, service (CSV) and error. On the frontend the traces page now has a “Live tail” switch + rows-limit selector (50/100/200/500/1000, persisted in localStorage) in the filter bar; incoming traces are prepended and the list is capped at the chosen limit. The tracesEventSource is torn down on onDestroy and on every filter-key change
Traces row click-to-expand with inline span list: clicking a trace row now toggles an inline panel below it that fetches GET /api/v1/traces/:traceId/spans once (cached per trace_id for the session) and shows a compact table with Service / Operation / Kind / Duration / Status for each span. The “View” action still opens the full trace detail page
Traces keyboard shortcuts: / focuses the Trace ID input, r refreshes, j/k move the selection down/up with scroll-into-view, enter expands/collapses the selected trace. Registered via shortcutsStore under scope traces and unregistered on destroy
Traces export (JSON / CSV): new Export popover in the filter bar produces a client-side download of the current list (disabled during live tail or when the list is empty). CSV covers start_time / service / operation / duration_ms / span_count / error / trace_id
Monitoring entry in the command palette: cmd+k now lists Monitoring under Navigation (icon + g o shortcut hint), matching the sidebar’s Detect group so the page is reachable without leaving the keyboard
trace_volume dashboard panel: new panel type that plots span count over time (optional errors line), configurable time range (1h/6h/24h/7d) and optional service filter. Mirrors the shape of the existing logs “Log Volume” panel but for OTLP traces. Reads the spans_hourly_stats / spans_daily_stats continuous aggregate first, falling back to the raw spans hypertable with date_trunc only when the cagg is empty for the window (covers the end_offset=1h refresh lag on freshly-ingested data). TimescaleDB-only
activity_overview dashboard panel: unified multi-series timeline combining logs, log errors, spans, span errors, detection events and alert triggers on a common bucket grid (hourly for ≤24h, daily for 7d/30d). Each series is individually toggleable in the config form. Per-source queries run in parallel and prefer the logs_*_stats, spans_*_stats, detection_events_*_stats continuous aggregates for cost; if a cagg returns zero rows for the window the fetcher falls back to the raw hypertable (logs, spans, detection_events) for that source only, so the panel stays correct on freshly-ingested data without running a raw scan in the hot path. Alerts always come from alert_history + alert_rules (no cagg exists for it)
Changed
Monitoring page reorganized into tabs: what was a single 1000-line scroll with three unrelated sections (Monitors, Incidents, Maintenance) plus a status-page config block wedged at the top is now a Tabs.Root with four tabs — Monitors, Incidents, Maintenance, Status page — each with its own header, its own primary CTA, and its own empty state. The Monitors tab gains a summary card grid (Total / Up / Down / Paused, each card clickable to filter the list by that status), a search input that matches name / target / type, and a “Clear filters” shortcut. Every row now has a one-click Pause/Resume button (previously buried in the edit form). Status-page visibility, password, slug and embed-badge config moved into the Status page tab. No changes to underlying API calls or permissions
Admin dashboard sidebar collapses on mobile: the 256px admin sidebar was previously always visible, taking up a huge chunk of the viewport on phones. It now hides below lg: and opens as a fixed drawer triggered by a hamburger in a new mobile-only sticky header that also shows the current section name (Dashboard / Users / Organizations / etc). The drawer has a backdrop, is dismissed on nav-click or backdrop-click, and locks body scroll while open. Desktop behaviour unchanged
User settings promoted from a dialog to a full page at /dashboard/account. The cramped scrollable UserSettingsDialog modal (profile + password + tutorial restart + danger zone stacked inside a 32rem-wide overlay) is now a proper route using a max-w-3xl container with each concern split into its own rounded-lg border bg-card section (Profile / Change password / Onboarding / Danger zone). The dropdown menu item still says “User Settings” but now navigates instead of opening an overlay. UserSettingsDialog.svelte is removed. The delete-account confirmation remains a nested AlertDialog since that one is genuinely destructive and modal-appropriate
Public status page polished for mobile and clarity: outer container shrinks from px-4 py-10 to px-3 py-6 on phones (only sm: and up gets the roomy desktop padding). The 45-day uptime bars drop their min-w-[6px] floor to 4px on mobile so the whole row fits comfortably on 320px viewports. The footer (“Last updated …” and “Powered by LogTide”) grows from text-[10px] to text-xs with a taller top margin, and “LogTide” is now styled as text-primary font-medium hover:underline so the link affordance is obvious (previously only a hover recolor). Incident update timestamps/status labels also bumped from text-[10px] to text-xs. The monitor-type badge (HTTP/TCP/etc.) hides below sm to free up the row for the name + uptime%. The password input switches from a fixed w-64 to w-full max-w-xs so it never overflows narrow phones
Monitoring forms moved into modal dialogs: the previous inline create/edit monitor form, the “New incident” form, the “Post incident update” form and the “Schedule maintenance” form were expanding/collapsing sections that shifted the whole page layout when opened. They now open as centered Dialog modals with max-h-[90vh] overflow-y-auto so tall forms (monitor create) scroll independently of the page. The per-row “Post update” Dialog is a single instance driven by a showUpdateForm id + an updatingIncident derived lookup, replacing the one-per-row inline expand. Form state, validations and submit handlers are untouched
Traces filter UI reworked into the same two-row pill bar as the search page: row 1 keeps Time Range (TimeRangePicker in a popover), the Live tail switch, the List/Map view toggle and the Export menu. Row 2 exposes every filter as its own always-visible pill — Project (single-select), Services (multi-select), Status (tri-state: all / errors / ok), Duration range (min/max ms inputs), Trace ID (direct-navigate to the trace detail page). Pills switch to the secondary variant when a non-default value is set; “Clear all” appears when any filter is active. Mobile popovers use w-[Xpx] max-w-[90vw] so they never overflow the viewport. handleTimeRangeChange syncs picker state back into timeRangeType / customFromTime / customToTime so the trigger label survives popover close/remount
Traces backend accepts multi-value projectId and service (CSV on the query string) and two new minDurationMs / maxDurationMs bounds on GET /api/v1/traces. The cross-project access check loops over every project in the list. tracesService.listTraces widens its input types to string | string[] and forwards to reservoir.queryTraces, which already supported these filters
Search page paginator now uses the same ellipsis/windowed control as the traces page: shows up to four numbered buttons plus sentinel … on each side when totalPages > 7. Falls back to the previous “Page N” label when the backend doesn’t return a total (storage engines without total in the query response). “Previous” / “Next” labels hide on <sm screens so only the chevron shows on phones
Empty states wait for the first load to complete before rendering on the search, traces, errors and security-incidents pages. Each page now tracks a hasLoadedOnce flag flipped in the finally branch of its loader; until the first fetch resolves (success or failure) the page shows its skeleton instead of the “No Logs Yet / Start sending logs from your applications” onboarding card. Prevents the flash where an org that already has data showed the onboarding CTA during the initial render between mount and the first query. When a fetch legitimately returns zero rows, the page also distinguishes “nothing matches these filters” (with a Clear-filters shortcut) from “project has no data at all” (the full onboarding)
Search page filter UI reworked into a two-row pill bar: row 1 keeps Search + mode, a Time Range popover trigger, Live Tail and Export inline. Row 2 exposes every filter as its own always-visible pill (Projects, Services, Hostnames, Levels, Trace ID, Session ID, Metadata) — each pill displays the current value (e.g. “Service: api-gateway”, “Levels: 2”) and opens its own popover for editing. Pills switch to the secondary variant when a non-default value is set so active filters are visible at a glance. A “Clear all” link appears at the end of the row when any filter is active. Popover widths use w-[min(Xpx,calc(100vw-1rem))] so they never overflow the viewport on phones. No semantic change to the underlying filter behavior (state, query params, live-tail flow, metadata apply/clear) - purely a layout/affordance pass replacing the previous six-column “Filters” Card
Auto-created Default dashboard now uses the new activity_overview panel instead of the logs-only time_series “Log Volume” chart. Existing organizations keep their current dashboard untouched; the swap only affects orgs that don’t yet have a default (i.e. new signups or orgs where the default was deleted and re-seeded via ensureDefaultExists)
Opt-in async buffer for reservoir writes: new ReservoirBuffered decorator sits between the ingestion API and the storage engine. When enabled via RESERVOIR_BUFFER_ENABLED=true, POST /api/v1/ingest returns as soon as records are accepted into a shard-partitioned queue; a flush consumer pool drains to storage asynchronously. Two transports ship in the box: an optimized in-memory queue with signal-based wakeup (single-instance, not crash-safe) and a Redis Streams transport with consumer groups, XAUTOCLAIM-based stale reclaim, atomic MULTI/EXEC nack, and a DLQ side stream (multi-instance, durable). Circuit breaker bypasses to sync ingestion when the buffer fills beyond a configurable pending threshold or after repeated flush failures in a rolling window. Off by default; see docs/async-buffer/ for when to enable and the per-engine benchmark table
IReservoir shared interface: public type that Reservoir and ReservoirBuffered both implement, so downstream code (backend monitoring, etc.) can type against IReservoir without caring which implementation is live
Prometheus-style buffer metrics: reservoir_buffer_enqueued_total, reservoir_buffer_bypass_total, reservoir_buffer_flush_success_total, reservoir_buffer_flush_failure_total, reservoir_buffer_flush_duration_ms (histogram), reservoir_buffer_dlq_total, reservoir_buffer_breaker_state (0 closed / 1 open / 2 half-open). All labelled by record kind and shard where applicable
Warning at startup when buffer is enabled on non-Timescale engines: benchmarks show the buffer regresses p95 under saturation on ClickHouse and MongoDB (the bottleneck is the flush side, which the buffer cannot hide). The backend now logs a clear warning with a link to the docs when RESERVOIR_BUFFER_ENABLED=true is set together with STORAGE_ENGINE=clickhouse or STORAGE_ENGINE=mongodb
k6 load-test script load-tests/buffered-vs-sync.js (pnpm --filter @logtide/backend load:buffered-vs-sync): constant-arrival-rate 100 req/s for 3 min, batch size 10, reports p50/p95/p99 + error rate. The file lives in the gitignored load-tests/ directory; only the npm script is checked in
Graceful shutdown now drains the reservoir buffer in parallel with Fastify app.close(): previously serialized, so a slow app.close() (BullMQ workers, websocket connections) could exhaust the container stop timeout before shutdownReservoir() ran. The new order calls them via Promise.all, giving the buffer its full RESERVOIR_BUFFER_GRACEFUL_SHUTDOWN_MS window to drain regardless of how long app.close() takes
Redis client in the backend’s buffer path is now explicitly quit()-ed on shutdown: RedisStreamTransport documents that it does not own the client, so the backend owns the lifecycle. Without this, the Redis connection was torn down by the process exit instead of closing cleanly
Buffer start-up failure is fail-fast: when RESERVOIR_BUFFER_ENABLED=true and the consumer pool cannot start (e.g. Redis down at boot), the backend logs a CRITICAL message and process.exit(1) instead of silently continuing with a non-flushing buffer
RedisStreamTransport hardening: XPENDING now filters by consumer name in claimStale, so delivery-count mapping is accurate even on busy streams with claims from other consumers; getStats.oldestPendingAgeMs now tracks the oldest not-yet-delivered entry (via XRANGE with exclusive start past the PEL tail) to match the documented contract; nack uses MULTI/EXEC so the DLQ write and the original ACK either both land or neither does; enqueueMany inspects pipeline exec() results and throws on the first error instead of swallowing them; default consumer name changed from consumer-${pid} to ${hostname}-${pid}-${randomHex} to avoid collisions between containers with the same PID
FlushConsumer isolates partial failures by kind: the three ingest calls (ingest, ingestSpans, ingestMetrics) now run via Promise.allSettled; the breaker records success only when all three succeed; metrics reflect per-kind reality; DLQ only counts the failed kinds on retry exhaustion. An optional FlushLogger can be injected; default falls back to console
FlushConsumerPool relaxes drain check to pendingRecords == 0: previously also required inflightRecords == 0, which never converged when another backend instance held claims on the same Redis consumer group. Consumer-task crashes are now surfaced via logger.error instead of being swallowed by Promise.allSettled. Drain poll bumped from 50 ms to 200 ms to reduce chatter
InMemoryTransport uses signal-based wakeup: dequeue no longer polls every 50 ms; enqueues wake waiters directly via a per-shard resolver list, and stop() unblocks waiters promptly. enqueueMany now batches records into a single Array.push(...entries) per shard plus one wake, eliminating the N-await bottleneck that made the in-memory buffer slower than sync on fast TimescaleDB workloads (p95 dropped from 4224 ms to 24 ms in the 3-min 100 req/s test)
Reservoir exposes getEngine() on the concrete Reservoir class (not on IReservoir): replaces the (inner as unknown as { engine }).engine cast inside ReservoirBuffered’s constructor. Kept out of the public interface so backend consumers typed against IReservoir cannot reach the engine and bypass the decorator
ReservoirBuffered.ingest/ingestSpans/ingestMetrics return the correct IngestResult shape: { ingested, failed, durationMs } instead of the previous { inserted } masked by as unknown as casts. durationMs measures the enqueue time, so the value reflects the real cost imposed on the caller
v0.9.2
Released
Feature
Metadata Filters, Admin User Creation & Org-Scoped Status URLs
Generic metadata filters across log search and alert rules, admin-driven user provisioning, editable org/project slugs, org-scoped public status page URLs (BREAKING), plus SIEM heatmap crash fix and three fastify/SvelteKit security bumps.
Generic metadata filter builder with 7 operators, backed by GIN-indexed JSONB queries and shared with alert rules
Configurable metadata.* columns in the log search table, persisted per project
Admins can create users from the dashboard (#198) without enabling public signup
BREAKING: public status page URLs moved to /status/:orgSlug/:projectSlug
Security: fastify 5.8.5 and SvelteKit 2.57.1 bumps (3 CVEs, 2 HIGH / 1 MEDIUM)
Show detailsHide details
Added
Generic metadata filters in log search and alert rules: the search page and alert rule dialogs now expose a “Metadata filters” section backed by a new MetadataFilterBuilder component. Supported operators: equals, not_equals, in, not_in, exists, not_exists, contains. Filters are applied server-side via a GIN-indexed JSONB query builder in reservoir; alert evaluation also runs the same matcher in-process so rules can fire only when a specific metadata field matches
Configurable metadata columns in log table view: users can add arbitrary metadata.* keys as extra columns in the search results table via a “Columns” picker. The selected column set is persisted per project in localStorage so it survives page reloads
Create user from admin panel (#198): admins can now provision new accounts directly from Admin → User Management via a “Create User” button, without having to temporarily re-enable public signup. Opens a dialog to set email, name, password and optional admin role. Backed by a new POST /api/v1/admin/users endpoint that bypasses the auth.signup_enabled gate and logs a create_user entry to the audit log
Set a custom dashboard as default from the UI: the dashboard switcher now shows a clickable star next to each org-wide, non-personal dashboard; clicking it promotes that dashboard to be the org’s default. Backed by a new POST /api/v1/custom-dashboards/:id/set-default endpoint that atomically unsets the previous default and sets the new one in a single transaction, respecting the existing partial unique index. Personal and project-scoped dashboards are rejected with a 400
Editable project slug from monitoring page: the status-page settings card in /dashboard/monitoring now exposes a “Public URL slug” input that lets the user rename a project’s slug, with inline validation against a shared format check (lowercase alphanumeric + hyphens, 2-50 chars), reserved-word list (api, admin, dashboard, status, auth, login, signup, logout, _app, health), and per-org uniqueness. Conflicts surface as 409 with a friendly inline error; race conditions are caught at the DB layer via the new composite unique index
Editable organization slug from settings: /dashboard/settings/general slug field is no longer read-only; owners can rename the org slug with the same validation rules and global uniqueness, with a warning that any existing status-page links and embed badges will break
Changed
Public status page URL is now scoped under the organization (BREAKING): page and badge URLs changed from /status/:projectSlug to /status/:orgSlug/:projectSlug. Affects the public web page, /api/v1/status/:orgSlug/:projectSlug/badge.svg, and /api/v1/status/:orgSlug/:projectSlug/badge.json. No redirect from the old URLs. Anyone embedding the badge SVG/JSON or sharing a status-page link must update the URL to include the org slug. Migration 040 simultaneously moves project-slug uniqueness from global back to per-org, so two different organizations can now both have a project named frontend without auto-suffixing
Fixed
Security dashboard crashed with “Cannot read properties of null (reading ‘toLowerCase’)” (#200): root cause was two parallel unnest(mitre_techniques) / unnest(mitre_tactics) calls in the same SELECT list of SiemDashboardService.getMitreHeatmap. PostgreSQL evaluates sibling set-returning functions in lockstep and NULL-pads the shorter array, so any detection event whose tactic and technique arrays had different lengths produced heatmap rows with a null tactic, which then crashed MitreHeatmap.abbreviateTactic on the frontend. The heatmap query now unnests techniques only and resolves each one to its canonical tactic via the shared MITRE_TECHNIQUES map (with sub-technique to parent fallback), eliminating the malformed pairs at the source. Frontend MitreHeatmap also filters cells with null tactic/technique and DetectionEventsList.getLogLevelClass defensively handles a null level as belt-and-suspenders
Security
Bump fastify to 5.8.5 (GHSA-247c-9743-5963, CVE-2026-33806, HIGH): body schema validation could be bypassed by prepending a single space to the Content-Type header. Parser and validator disagreed on how to trim the header, so the body was still parsed but the schema lookup returned no validator and validation was skipped entirely. Upgraded from ^5.8.3 to ^5.8.5
Bump @sveltejs/kit to 2.57.1 (GHSA-2crg-3p73-43xp, CVE-2026-40073, HIGH): BODY_SIZE_LIMIT could be bypassed under certain conditions in adapter-node. Tightened the pnpm override from >=2.53.3 to >=2.57.1
Bump @sveltejs/kit to 2.57.1 (GHSA-3f6h-2hrp-w5wx, CVE-2026-40074, MEDIUM): calling redirect inside the handle hook with a location containing characters invalid for an HTTP header threw an unhandled TypeError, enabling DoS if the location included unsanitized user input
v0.9.1
Released
Fix
Security Hardening, Input Validation & Bug Fixes
SQL injection fix in querySpans, ReDoS protection for monitor assertions, 25+ bug fixes across auth, pipelines, SIEM, monitoring, and SSR safety, plus comprehensive input validation on all route params.
SQL injection fix in querySpans sortBy/sortOrder for both TimescaleDB and ClickHouse
ReDoS protection on HTTP monitor body assertion via safe-regex2
UUID validation, positive-int clamping, and max-length guards across all route params
PII salt race condition, SSE duplicate sends, and Sigma sync FK corruption fixed
localStorage SSR crash guards and URL-encoding fixes in frontend navigation
Show detailsHide details
Fixed
Identifier pattern update/delete failed with “Organization ID is required” (#193): PUT and DELETE routes required organizationId as an explicit query param or API key context, but session-based auth never sets that field. Now falls back to the user’s first organization, consistent with GET and POST
Project rename failed with “Expected string, received null” (#195): updateProjectSchema used z.string().optional() which rejects null, but projects without a description send null from the DB. Changed to z.string().nullable().optional() and updated UpdateProjectInput accordingly
Pipeline create/preview/import returned generic “Validation error” (#193, #194): POST routes expected organizationId in the request body but the frontend sends it as a query param. Routes now merge the query param into the body before Zod validation. Frontend error messages now surface the first validation detail instead of the generic label
Invitation accept race condition: wrapping the membership check + insert in a transaction caused the accepted_at update to roll back when throwing “already a member”. Split the early-exit path out of the transaction and added 23505 unique-constraint handling for true concurrent accepts
SSE live tail duplicate sends: latestLog picked the oldest entry from a DESC-sorted array instead of the newest, causing every poll to re-fetch and re-send all logs since the oldest timestamp. Replaced with a defensive max-time computation
Sigma sync corrupted alert_rule_id FK: fallback alertRuleId || existing.id wrote the sigma rule’s own PK into the alert_rules FK column when no alert was auto-created. Now omits the column entirely unless a new alert rule was just created
Exception log viewer returned empty results for org-wide error groups: getLogsForErrorGroup passed an empty string as projectId to reservoir when no project filter was set. Now groups log IDs by their exception’s project_id and issues one getByIds call per project
ReDoS in HTTP monitor body assertion: user-supplied regex pattern was compiled with only a 256-char length limit, no catastrophic-backtracking check. Added safe-regex2 validation and a compile-error catch
OTLP int64 precision loss: parseInt() silently truncated int64 attribute values exceeding Number.MAX_SAFE_INTEGER. Unsafe values are now kept as strings in metadata
PII salt race condition fallthrough: if two workers raced on the first hash for an org, the loser could return an unpersisted local salt when the retry read failed, permanently desynchronizing PII hashes. Now scopes the catch to 23505 and throws on unexpected errors
Monitor status fallthrough on missing row: processCheckResult silently skipped the entire state machine (no status update, no notifications) when monitor_status was undefined. Now re-reads from DB or creates a default row before proceeding
Test isolation: auth mode pollution across test files: system_settings was never reset between tests, so any test setting auth.mode='none' caused 12 unrelated “401 without auth” assertions to return 503. Added cleanup + cache invalidation to global beforeEach
Sigma detection tests used sigma_id after code migrated to id: fixture data still passed rule.sigma_id as sigmaRuleId, which would not match the new .where('id', '=', ...) queries
translateDelete dropped level filter in reservoir: pushFilter return value was discarded for the level condition, corrupting $N parameter slots when both service and level filters were present
SQL injection in querySpans via sortBy/sortOrder: user-controlled strings were interpolated directly into raw SQL in both TimescaleDB and ClickHouse engines. Added explicit column/direction allowlists
ingestSpans hardcoded ::uuid[] for project_id: ignored the projectIdType engine option, breaking text-based project IDs with a Postgres cast error
localStorage SSR crash in organization store: 7 direct localStorage calls without a browser guard would throw ReferenceError during server-side rendering. Added browser check on all accesses
Invite token not URL-encoded in redirect: goto(/login?redirect=/invite/${token}) corrupted the redirect path for tokens containing +, =, or /. Now wraps in encodeURIComponent
ruleId not URL-encoded in security dashboard navigation: inconsistent with serviceName and technique which already used encodeURIComponent
Missing UUID validation on monitoring route params: all /:id handlers passed request.params.id directly to DB queries without format validation. Added Zod .uuid() parsing on every route
Negative limit/days in monitoring routes: Number("-1") || 50 evaluates to -1 (truthy), passing a negative value to LIMIT. Replaced with a parsePositiveInt guard that clamps to [1, max]
Missing UUID validation on status-incident route params: same issue as monitoring - :id params were used unvalidated in DB queries
SIEM comment body has no max length: z.string().min(1) with no upper bound allowed arbitrarily large comment payloads. Added .max(10000)
Notifications and alerts limit/offset NaN passthrough: parseInt("abc") returned NaN, which was passed to Kysely .limit(NaN). Added safe integer parsing with fallback and max cap
Correlation referenceTime accepted invalid date strings: schema validated only {type: 'string'}, so new Date("garbage") flowed into Kysely WHERE clauses as Invalid Date. Added format: 'date-time' and a defensive 400 response
Log pipeline comment contradicted jsonb merge direction: code comment said “do NOT overwrite existing” but the in-progress fix had flipped the jsonb || operand order so pipeline fields now win. Updated comment to match actual behavior
v0.9.0
Released
Feature
Service Monitoring, Log Pipelines & Custom Dashboards
Three major features: uptime monitoring with public status pages (#152), log parsing and enrichment pipelines with grok and GeoIP, and fully customizable dashboards with 9 panel types (#151).
Service health monitoring with HTTP/TCP/heartbeat checks and public Uptime Kuma-style status pages
Log parsing pipelines with 5 built-in parsers, custom grok patterns, and GeoIP enrichment
Custom dashboards with 9 panel types, drag-and-drop reorder, resize, and YAML import/export
Scheduled maintenances and manual status incidents on public status pages
Versioned dashboard schema with migration framework and cross-org isolation guards
Show detailsHide details
Added
Service health monitoring and status pages (#152)
Proactive uptime monitoring with auto-generated public status pages.
3 monitor types: HTTP/HTTPS (configurable method, expected status, headers, body assertion), TCP ping, and heartbeat (alert when no ping received within grace window)
HTTP config: per-monitor httpConfig with method, expectedStatus, custom headers, and body assertion (contains or regex) - stored as JSONB, validated via Zod
Per-monitor severity: incident severity is configurable per monitor (critical, high, medium, low, informational) instead of hardcoded high
BullMQ-style polling: worker checks all due monitors every 30s, batched in groups of 20 concurrent checks via Promise.allSettled
TimescaleDB storage: monitor_results hypertable with 7-day compression, 30-day retention, and monitor_uptime_daily continuous aggregate refreshed hourly
State machine: consecutive failure tracking with configurable threshold, atomic incident dedup guard (WHERE incident_id IS NULL), auto-resolve on recovery
Auto-incident creation: when failure threshold is crossed, a SIEM incident is created with source: 'monitor' and linked via monitor_id; notifications sent via existing email/webhook channels
Public status page (/status/:projectSlug): Uptime Kuma-inspired design with 45-day heartbeat bar grid, per-monitor uptime badge, overall status banner, custom CSS tooltips, light/dark mode toggle
Status page access control: configurable visibility per project - disabled (default), public, password-protected, or org-members-only
Scheduled maintenances: create maintenance windows with start/end times; active maintenances suppress monitor incident creation and display a banner on the status page
Manual status incidents: create public incident communications (investigating → identified → monitoring → resolved) with update timeline, independent from SIEM incidents
Heartbeat endpoint: POST /api/v1/monitors/:id/heartbeat accepts both API key and session auth, rate-limited to 600/min
Project slugs: auto-generated from project name on creation, unique per org, backfilled for existing projects via migration
Dashboard UI (/dashboard/monitoring): monitor list with project selector, create/edit/delete forms with client-side validation, detail page with refresh button, uptime chart, recent checks, copy heartbeat URL
Monitoring navigation: added to sidebar under “Detect” section alongside Alerts and Security
Log parsing and enrichment pipelines
Define multi-step processing rules that automatically parse and enrich incoming log messages before they are stored.
5 built-in parsers: nginx (combined log format), apache (identical to nginx), syslog (RFC 3164 and RFC 5424), logfmt, and JSON message body
Custom grok patterns: %{PATTERN:field} and %{PATTERN:field:type} syntax with 22 built-in patterns (IPV4, WORD, NOTSPACE, NUMBER, POSINT, DATA, GREEDYDATA, QUOTEDSTRING, METHOD, URIPATH, HTTPDATE, etc.) and optional type coercion (:int, :float)
GeoIP enrichment: extract country, city, coordinates, timezone, and ISP data from any IP field using the embedded MaxMind GeoLite2 database
Async processing via BullMQ: pipelines run as background jobs after ingestion - zero impact on ingestion latency
Project-scoped vs org-wide: pipelines can target a specific project or apply to all projects in the organization; project-specific pipelines take priority over org-wide ones
Pipeline preview: test any combination of steps against a sample log message and inspect per-step extracted fields and the final merged result before saving
YAML import/export: import pipeline definitions from YAML with name, description, enabled, and steps fields; upserts (replace existing pipeline for the same scope)
In-memory cache: getForProject caches the resolved pipeline per project for 5 minutes, automatically invalidated on create/update/delete
Settings UI (/dashboard/settings/pipelines): list, enable/disable toggle, create, edit, and delete pipelines with live org-switch reactivity ($effect instead of onMount)
Step builder: interactive UI for adding, reordering, and configuring parser, grok, and geoip steps with per-type configuration forms
Pipeline edit page redirects to the list when the active organization is switched, preventing stale-ID errors
Custom dashboards with configurable panels (#151)
User-built dashboards replace the previous fixed /dashboard page, with team-specific views across all observability domains.
9 panel types covering every data source: time series, single stat, top-N table, live log stream, alert status (logs/alerts), metric chart and metric stat (OTLP metrics with avg/sum/min/max/count/last/p50/p95/p99 aggregations), trace latency (p50/p95/p99 from spans), detection events (SIEM by severity), monitor status (uptime + response time)
Panel registry architecture: adding a new panel type touches only 6 files (shared types, backend Zod schema, backend fetcher, frontend panel component, frontend config form, frontend registry entry); the renderer, container, store, and routes never need to change
Drag-and-drop reorder via svelte-dnd-action with optimistic local state and a single PUT save
Drag-to-resize with bottom-right pointer-event handle, snapping to grid units; constrained by per-type min width/height from the registry
Responsive 12/6/1 column grid that collapses panels to 6 columns on tablet (640-1024px) and 1 column on mobile (<640px); stored widths are always in the canonical 12-col reference and scale proportionally
Auto-created Default dashboard per organization, idempotent via Postgres unique-violation guard, replicating the previous fixed layout (4 stat cards + log volume + top services + top error messages) so existing users see no visual change
Inline edit mode with toggle, no separate edit page; pending changes are kept in a snapshot and discarded on Cancel
Per-panel configuration dialogs with type-specific forms (level toggles, intervals, aggregation pickers, percentile selectors)
Dashboard switcher dropdown in the page header with personal/shared distinction, create, delete (default protected), import, export
YAML import/export: dashboards round-trip through YAML for version-controlling alongside infrastructure code; import regenerates panel IDs and uses JSON_SCHEMA to block JS-tag prototype pollution
Versioned JSON schema (schema_version: 1) with a migration framework in @logtide/shared: each version writes a MigrationFn indexed by target version, migrateDashboard walks the chain on every read; clamps out-of-range versions defensively
Cross-org isolation guard: every panel data fetch verifies that config.projectId belongs to the requesting org, preventing data leaks via crafted YAML imports or stale references
Batch panel data endpoint (POST /:id/panels/data): single round-trip fetches all panel data via Promise.allSettled, individual panel errors do not fail the dashboard
Organization scoping: dashboards are org-scoped with optional is_personal flag (only visible to creator) and created_by tracking; partial unique indexes prevent multiple defaults per (org, project) scope
Migration 039_custom_dashboards.sql: JSONB panels column with GIN index for future panel-type filtering, partial unique indexes for default scope guarantees
Fixed
Status page slug collision: getPublicStatus now filters by status_page_public flag instead of returning the first project matching the slug, preventing cross-org data leaks
createMonitor not transactional: monitor and monitor_status inserts are now wrapped in db.transaction() to prevent orphaned monitors
mapMonitor typed: replaced any parameter with proper MonitorWithStatusRow interface for compile-time safety
Org membership check optimized: monitoring routes now use a single SELECT WHERE user_id AND organization_id query instead of fetching all user orgs and scanning in JS
Redundant DB read eliminated: processCheckResult now receives status data from the already-fetched monitor object instead of issuing a second SELECT
Target validation on update: PUT endpoint now validates target format against monitor type (HTTP must start with http:///https://, TCP must contain :)
$derived.by fix: monitor detail page uptime calculation now uses $derived.by() instead of $derived(() => ...) for correct Svelte 5 reactivity
@const placement: replaced invalid {@const} inside <div> elements with {#if}/{:else} blocks for Svelte 5 compatibility
uptimePct type coercion: Postgres ROUND() returns numeric as string - status page now coerces to number before calling .toFixed()
Default failureThreshold aligned: frontend form default changed from 3 to 2 to match backend default
Test setup cleanup: added monitor_results, monitor_status, monitors, incident_alerts to global beforeEach cleanup
Quick Start cURL example now validates without a manual timestamp, Sigma worker no longer floods logs on empty detection batches, and fresh instances without INITIAL_ADMIN_* env vars auto-promote the first registered user to admin.
logSchema now defaults time to current ISO string so minimal payloads and copy-paste examples validate
Sigma worker [SigmaDetection] No matches found log gated behind DEBUG_SIGMA=true
First registered user is auto-promoted to admin when no INITIAL_ADMIN_* is set and no admin exists
Show detailsHide details
Fixed
Quick Start cURL example failed validation: the empty-state code snippet sent {logs: [{level, service, message}]} without a time field, but logSchema required it for the standard ingestion path (only the array-format path ran normalizeLogData). The schema now defaults time to the current ISO string when missing, so copy-paste examples and minimal payloads validate without requiring users to inject a timestamp.
Noisy Sigma worker logs: [SigmaDetection] No matches found was emitted at info level on every batch with no detections, flooding worker output in normal operation. The line is now gated behind DEBUG_SIGMA=true so it only appears when explicitly opted in.
*No admin user when INITIAL_ADMIN_ not set (#188)**: on a fresh instance without INITIAL_ADMIN_EMAIL/INITIAL_ADMIN_PASSWORD, no usable admin existed and admin settings were unreachable. The bootstrap no longer creates a system fallback user; instead, the first user to register (via /register or external auth provider) is automatically promoted to admin if no admin exists yet.
Fixed ClickHouse traces/metrics data-availability queries failing due to raw epoch date parameters, and resolved stale dashboard sessions after volume resets.
ClickHouse traces/metrics data-availability queries now use toDateTime64() clamp instead of raw epoch dates
Dashboard validates session token against backend on load and auto-logs out on invalid session
Show detailsHide details
Fixed
ClickHouse traces/metrics data-availability always empty: queryTraces and queryMetrics passed raw 0 for epoch dates as DateTime64(3) parameter, which ClickHouse can’t parse; now uses the same toDateTime64() clamp used by log queries
Stale session after volume reset: dashboard only checked localStorage for a token without validating it against the backend; now calls /auth/me on load and auto-logs out if the session is invalid
Five cross-org and auth security fixes, corrected SSE real-time streams, resolved 20+ Svelte memory leaks on navigation, and multiple bug fixes across SIEM, incidents, webhooks, and retention jobs.
Cross-org isolation and auth bypass fixes in SIEM and pattern routes
SSRF protection for legacy webhook path and disabled-user login blocked
SSE real-time events and log stream duplicate emission fixed
20+ Svelte memory leaks from unsubscribed auth stores resolved
Docker config sync and dependency security bumps (picomatch, brace-expansion, fast-xml-parser)
Show detailsHide details
Security
Cross-org isolation fix in SIEM: linkDetectionEventsToIncident now scopes detection events to the requesting organization, preventing cross-tenant data corruption via crafted API calls
Cross-org auth bypass in pattern routes: PUT and DELETE handlers for correlation patterns now verify organization membership before mutating data (same check GET/POST already had)
SSRF protection for legacy webhook path: the alert-notification job’s direct fetch() call now validates URLs against private/internal IP ranges, matching the WebhookProvider safeguard
Disabled user login blocked: POST /login now checks the disabled flag before creating a session, preventing disabled accounts from obtaining tokens
Expired invitation info leak: getInvitationByToken now filters on expires_at > NOW(), preventing enumeration of expired invitation details
Fixed
SIEM dashboard timeline crash: time_bucket() call was missing ::interval cast on the parameterized bucket width, causing a PostgreSQL type error that broke the timeline widget for all users
SSE real-time events broken: SIEM store and incident detail page read auth token from localStorage('session_token') (wrong key), so the SSE connection never authenticated; now uses getAuthToken() from the shared auth utility
SSE log stream duplicate emission: when multiple logs shared the same timestamp, the inclusive from bound caused them to be re-sent on every poll tick; stream now tracks sent log IDs to deduplicate
Incident severity auto-grouping wrong: MAX(severity) used PostgreSQL alphabetical ordering (medium > critical), producing incorrect severity on auto-grouped incidents; now uses ordinal ranking
Sigma notification failures silent: notification job payload was missing organization_id and project_id, and markAsNotified was called with null historyId; both now handled correctly
Incidents pagination total always zero: loadIncidents in the SIEM store never wrote response.total to incidentsTotal
Memory leaks on navigation: 20+ Svelte components called authStore.subscribe() without cleanup; all now store the unsubscribe function and call it in onDestroy
offset=0 silently dropped: API client functions used if (filters.offset) which is falsy for zero, so page-1 requests never sent the offset parameter; changed to if (filters.offset != null)
Search debounce timer leak: searchDebounceTimer was not cleared in onDestroy, causing post-unmount API calls when navigating away mid-search
verifyProjectAccess double call: when projectId is an array, the first element was verified twice (once before the loop, once inside it); consolidated into a single loop
updateIncident silent field skip: title, severity, and status used truthy checks (&&) instead of !== undefined, inconsistent with description and assigneeId
Webhook error messages empty: response.statusText is empty for HTTP/2; error now reads the response body for useful detail
Retention job crash on empty orgs: Math.max(...[]) returns -Infinity, cascading to an Invalid Date in the drop_chunks call; early return added when no organizations exist
escapeHtml DOM leak: PDF export’s escapeHtml created orphaned DOM nodes in the parent document; replaced with pure string replacement
Webhook headers validation missing: CreateChannelDialog silently swallowed invalid JSON in the custom headers field; now validates on submit
getIncidentDetections no org scope: query now accepts optional organizationId for defense-in-depth filtering
Stale shared package types: dist contained outdated Project and Incident interfaces with phantom fields (slug, statusPageVisibility, source, monitorId); rebuilt from source
Changed
Docker config sync: docker-compose.build.yml now matches docker-compose.yml with all environment variables (MongoDB, TRUST_PROXY, FRONTEND_URL, INTERNAL_DSN, DOCKER_CONTAINER), MongoDB service, and fluent-bit-metrics service
NODE_ENV for backend: production docker-compose.yml now sets NODE_ENV: production on the backend service (worker and frontend already had it)
docker/.env.example: added STORAGE_ENGINE, ClickHouse, and MongoDB configuration sections
Dependencies
picomatch 4.0.3 → 4.0.4 (fix ReDoS via extglob quantifiers + POSIX character class method injection)
All dashboard pages now show content-shaped skeleton loaders, automated Helm chart releases on every stable Docker image, and multiple performance and correctness fixes for API endpoints and admin pages.
Skeleton loaders and loading overlays on all dashboard pages
Automated Helm chart releases triggered on every stable Docker image release
API 400 responses now include field-level validation errors
Fixed admin pages returning 502 on direct load/reload
POST /api/v1/logs/identifiers/batch performance: bypasses storage engine, one PostgreSQL query
GET /api/v1/logs/hostnames 8+ second queries fixed with engine-specific indexes and a 6h window cap
Show detailsHide details
Added
Skeleton loaders and loading overlays: all dashboard pages now show content-shaped loading states instead of blank spinners
New Skeleton, SkeletonTable, and TableLoadingOverlay components (src/lib/components/ui/skeleton/)
Directional shimmer animation via @keyframes shimmer using design tokens — works in light and dark mode, disabled for prefers-reduced-motion
Initial load (no data yet): animated skeleton rows mirroring the page layout — stat cards on /dashboard, project cards on /dashboard/projects, table rows on search, traces, errors, admin tables, incidents, alerts history, and members
Re-fetch (filter change, pagination): existing content dims with a translucent overlay and centered spinner, preventing layout shift and context loss
Automated Helm chart releases: every stable Docker image release now triggers a repository_dispatch to logtide-dev/logtide-helm-chart, which auto-bumps appVersion and chart version (patch), commits, and publishes a new chart release to the Helm repo on GitHub Pages
Fixed
API 400 responses now include a details array with field-level validation errors instead of just a generic message. Covers both Fastify/AJV schema validation and Zod validation errors (including uncaught ZodError that previously returned 500)
Admin pages returned 502 Bad Gateway on direct load/reload: the admin layout ([email protected]) breaks out of the dashboard layout chain, so ssr = false was not inherited; added a dedicated +layout.ts to the admin section
/dashboard/admin/projects/[id] crashed with “Something went wrong” due to formatDate being called but not defined (function was named formatTimestamp)
POST /api/v1/logs/identifiers/batch slow: the route was calling reservoir.getByIds (hitting ClickHouse/TimescaleDB/MongoDB) only to verify project access, then querying log_identifiers (PostgreSQL) separately. Since log_identifiers already stores log_id → project_id + identifier data, the storage engine call is now bypassed entirely — one PostgreSQL query replaces the N×storage-engine-roundtrips loop. Added bloom filter skip index on id in ClickHouse and a standalone id index in TimescaleDB (migration 032) for getByIds used by findCorrelatedLogs
GET /api/v1/logs/hostnames taking 8+ seconds: the 6h window cap was only applied when from was absent — explicit from params (e.g. 24h range from the search page) bypassed it and triggered a full-range metadata scan; cap now clamps any window to 6h max. Added limit: 500 to the distinct call. Per-engine optimizations: ClickHouse adds a hostname materialized column (computed at ingest, eliminates JSONExtractString at query time) and uses it directly in distinct queries; TimescaleDB adds a composite expression index (project_id, (metadata->>'hostname'), time) (migration 032); MongoDB adds a sparse compound index on metadata.hostname. All three engines also now extract the metadata field in a subquery (once per row vs 3×)
v0.8.3
Released
Feature
Comprehensive Audit Logging & OIDC Brand Icons
Major expansion of the audit trail system covering all critical platform actions for GDPR/SOC2 compliance, OIDC provider brand icons, and fixes for date formatting localization and data availability routing.
Audit logging for log access, auth events, identity management, provider config, settings, and sessions
Backend auto-detects OIDC provider icon from issuer URL
Date/number formatting now respects user's browser/system locale
Fixed data-availability endpoint for ClickHouse and MongoDB storage engines
Show detailsHide details
Added
Comprehensive Audit Logging: major expansion of the audit trail system to cover all critical platform actions for improved compliance (GDPR/SOC2) and security monitoring.
Log Access Auditing: every log search, trace view, context lookup, single log detail view, and live stream connection is now recorded with user identity, IP address, and query parameters.
External Authentication Auditing: successful logins via OIDC and LDAP providers are now tracked, including new user registration events.
Identity Management Auditing: linking and unlinking of external identities (Google, GitHub, LDAP, etc.) to user accounts is now recorded.
Authentication Provider Auditing: all administrative actions on auth providers (create, update, delete, reorder) are now fully audited with configuration change summaries.
System Settings Auditing: any changes to global platform settings (auth mode, signup status, default users) are now tracked with before/after metadata.
Session Auditing: viewing of active session lists and individual session event timelines is now recorded.
Audit metadata now includes detailed context like search queries (q), filter parameters, and updated keys for configuration changes.
OIDC login page now shows brand icons for well-known providers (Google, Microsoft/Azure, GitHub, GitLab, Okta, Auth0, Keycloak, Authentik); unknown providers fall back to the generic icon
Backend auto-detects the provider icon from the issuer URL when creating or updating an OIDC provider, with name/slug matching as fallback for self-hosted setups
Fixed
Date and number formatting localization: removed hardcoded locales (it-IT, en-US) from the frontend (SIEM, Search, Admin, etc.) to ensure the application automatically respects the user’s browser/system language settings.
GET /api/v1/projects/data-availability returned logs: [] (and incorrect traces/metrics) when STORAGE_ENGINE=clickhouse or mongodb; the endpoint now routes all three checks through the reservoir so they hit the correct backend
v0.8.2
Released
Fix
SigmaHQ Auto-Sync, Trace Links & Security Fixes
Daily auto-sync for SigmaHQ community rules, trace navigation from log detail panel, audit logs for alert rules, and security/robustness fixes for ingestion and admin endpoints.
SigmaHQ rules auto-sync daily at 2:30 AM
View Trace link in log detail panel when trace_id is present
Audit log entries for alert rule create/update/delete
Admin pagination capped at 200 to prevent oversized allocations
NDJSON lines exceeding 1MB rejected with HTTP 400
api_key_id removed from log metadata (information disclosure fix)
Show detailsHide details
Added
SigmaHQ auto-sync: SigmaHQ community rules now auto-sync daily at 2:30 AM for organizations that have existing community rules enabled.
Trace link in log detail panel: The log detail panel now shows a “View Trace →” link that navigates directly to the trace timeline when a trace_id is present in the log.
Audit logs for alert rules: Create, update, and delete operations on alert rules now generate audit log entries.
Fixed
Admin pagination cap: The limit parameter on admin list endpoints is now capped at 200, preventing oversized result set allocation.
NDJSON ingestion size limit: Individual lines exceeding 1 MB in NDJSON payloads are now rejected with HTTP 400 instead of being silently processed.
Log metadata information disclosure: api_key_id is no longer stored in log metadata. It was previously injected in v0.8.1 for exception log display but exposed sensitive data; the display now resolves the key name at read time instead.
v0.8.1
Released
Breaking
Project Visibility in Errors & Storage Engine Fix
Project name display in error pages, API Key visibility in exception logs, and a fix for data-availability routing when using ClickHouse or MongoDB.
All existing API Keys have been invalidated — regenerate your keys
Project name shown in Exceptions list and error group detail pages
API Key name visible in recent exception logs
Data-availability endpoint now respects storage engine selection
Search page project filter fixed for empty logs array
Show detailsHide details
Breaking Changes
All API Keys have been invalidated: All existing API Keys were deleted as part of this release. You must regenerate your API Keys from the dashboard and update your SDKs and ingestion clients accordingly.
Added
Project visibility in Exceptions: The /dashboard/errors list and the individual error group detail pages now explicitly display the name of the project that generated the error.
API Key visibility in Exception logs: The recent logs tab within an error group detail page now displays the specific API Key name used to ingest the log. Ingestion now injects the api_key_id into log metadata.
Fixed
Project data-availability ignoring storage engine: GET /api/v1/projects/data-availability was always querying the PostgreSQL logs table via Kysely, returning logs: [] when STORAGE_ENGINE was set to clickhouse or mongodb. The logs check now uses reservoir.distinct() which routes to the correct storage backend.
Search page showing no projects when logs is empty array: the project filter guard logsProjectIds ? was truthy for [], filtering out all projects. Changed to logsProjectIds?.length so an empty array correctly falls back to showing all projects.
MongoDB storage adapter for @logtide/reservoir, browser and frontend SDKs with source maps and Core Web Vitals, and OTLP metrics dashboards with cross-signal correlation.
MongoDB storage adapter for @logtide/reservoir
Browser/Frontend SDKs with session tracking & source maps
Core Web Vitals and network breadcrumbs
OTLP Metrics rollups & Golden Signals (P50/P95/P99)
Correlation across logs, traces, and metrics
Show detailsHide details
Added
Browser & Frontend SDK Enhancements — Sentry-level browser observability across all frontend framework SDKs.
@logtide/browser: Dedicated browser SDK with session tracking, Web Vitals, and offline resilience.
Source Maps: @logtide/cli for source map uploads and backend un-minification service.
Frameworks: Deep integrations for Next.js, Nuxt, SvelteKit, and Angular.
Metrics Dashboard & Rollups — Redesigned metrics page with pre-aggregated rollups for TimescaleDB and ClickHouse.
MongoDB Storage Adapter — Full MongoDB backend for the @logtide/reservoir storage layer.
Golden Signals with Percentiles — P50/P95/P99 percentile aggregation across all storage engines.
Smart Project Selectors — Dropdowns now only show projects that have data in the relevant category.
Optimized
TimescaleDB Skip-Scan — Recursive CTEs for instant distinct queries on high-cardinality fields.
Intelligent Volume Estimation — countEstimate support for instant dashboard loads on high-volume projects.
Batch Ingestion — Ordered-false inserts and tuned connection pools for maximum throughput.
ClickHouse Projections — Faster query execution for common search patterns.
Fixed
Internal Logging Plugin — Fixed DSN passing to @logtide/fastify for self-monitoring.
Backend Self-Monitoring — Improved DSN construction and verbose startup logging.
Docker Compose — Fixed missing environment variables and worker health check race conditions.
Admin Dashboard — Fixed missing metrics series in platform activity chart.
v0.6.4
Released
Improvement
Flexible Batch Ingestion Formats
POST /api/v1/ingest now accepts direct arrays and wrapped arrays in addition to the standard format, improving compatibility with collectors like Vector and Fluent Bit.
Standard format {"logs": [...]} unchanged
Direct array payloads (Vector with codec: json)
Wrapped array payloads (Vector with VRL wrapping)
Array formats auto-normalize time, level, and service fields
Show detailsHide details
The batch ingestion endpoint POST /api/v1/ingest now accepts flexible payload formats for better collector compatibility (Vector, Fluent Bit, etc.):
Standard format: {"logs": [{...}]} — unchanged
Direct array: [{log1}, {log2}] — Vector with codec: json
Wrapped array: [{"logs": [{...}]}, ...] — Vector with VRL wrapping
Array formats auto-normalize fields: time is auto-generated when missing, level is normalized, and service is extracted from the payload.
v0.7.0
Released
Feature
OTLP Metrics, Service Graph & Audit Log
OpenTelemetry metrics ingestion, service dependency graph visualization, audit logging, and major UX restructuring.
OTLP Metrics with protobuf/JSON support
Service Dependency Graph with force-directed layout
Audit Log for compliance tracking
UX sidebar restructured into Observe/Detect/Manage
46 TypeScript/Svelte warnings eliminated
Show detailsHide details
Added
OTLP Metrics Ingestion — Full OpenTelemetry metrics support with protobuf/JSON, all 5 metric types, exemplar support, TimescaleDB hypertables, ClickHouse support, query API with 7 aggregation intervals and 6 functions, group-by label support, 118+ tests
Service Dependency Graph — Force-directed graph visualization with health color-coding, click-to-inspect panels, and PNG export
Audit Log — Tracks 4 event categories (login, config changes, user management, data modifications) with high-performance buffer and CSV export
Changed
Batch ingestion endpoint accepts flexible payload formats (standard, direct array, wrapped array) with auto-normalization
UX Restructuring — Sidebar grouped into Observe/Detect/Manage sections, Service Map merged into Traces, Sigma Rules moved to Security, Settings restructured, Command palette updated
Fixed
OTLP Traces typo using resource_logs instead of resource_spans
OTLP Authentication for /v1/otlp routes
JavaScript SDKs updated to v0.6.1 for OTLP compatibility
Frontend environment loading via $env/dynamic/public
SDK code examples across dashboard
Pagination total count with fast approximate estimates
SMTP no longer requires user/password credentials. Only the host is needed, and the from address uses the SMTP_FROM parameter.
SMTP works without USER/PASS credentials
From address uses SMTP_FROM parameter
Show detailsHide details
Fixed SMTP configuration to support unauthenticated mail servers. Only SMTP_HOST is now required — SMTP_USER and SMTP_PASS are optional. The sender address is configured via SMTP_FROM.
v0.6.2
Released
Breaking
Write-Only API Keys & Domain Allowlists
New write-only API key type safe for browsers and mobile, plus domain/IP allowlists with wildcard subdomain support for origin validation.
Write-only API keys safe for client-side use
Domain/IP allowlist with wildcard subdomains
Dogfooding SDK migration to official plugins
fast-xml-parser security fix
Show detailsHide details
Added
Write-Only API Keys — New type field (write/full), safe for browsers and mobile, defaults to write
Domain/IP Allowlist — Up to 50 allowed origins per key with wildcard subdomain support and Origin header validation
Security
fast-xml-parser bumped to >=5.3.6 for entity expansion DoS fix
Read endpoints reject write-only API keys with 403
Origin allowlist validation with wildcard subdomain parsing
Breaking Changes
API key default type changed to write — existing keys migrated automatically, server-side queries need full type
ClickHouse as a full alternative to TimescaleDB via the @logtide/reservoir abstraction layer, with factory pattern engine selection and full query migration.
ClickHouse via @logtide/reservoir abstraction
Factory pattern engine selection
26 integration tests against both engines
Full log query migration to Reservoir
Show detailsHide details
Added
ClickHouse Storage Engine — Full support as TimescaleDB alternative via @logtide/reservoir abstraction layer with PREWHERE, async_insert, and ngrambf_v1 indexes
Full Log Query Migration — All query operations (alerts, dashboard, admin, retention, ingestion, correlation) migrated to the Reservoir abstraction
Performance
Removed COUNT(*) full scans in admin queries, switched to continuous aggregates
ClickHouse DateTime64(3) millisecond precision with hasToken() fallback
PII Masking at Ingestion — Content patterns (email, credit card, phone, SSN, IPv4, API keys), field name masking, custom rules, three strategies (mask, redact, hash with per-org salt), Settings UI with live test panel
Terminal log view with ANSI color coding, pre-configured detection packs, event correlation by identifier, alert preview testing, and optional Redis dependency.
Terminal Log View with ANSI color coding
Pre-configured Sigma detection packs
Event correlation by request/trace/user ID
Alert preview with 'Would Have Fired' simulation
Redis dependency now optional
Show detailsHide details
Added
Terminal Log View — ANSI-style color coding for a familiar terminal experience
Detection Packs — Pre-configured Sigma rule bundles for common scenarios (startup, auth, database health)
Event Correlation — Click any request ID, trace ID, or user ID to see all related logs
Alert Preview — “Would Have Fired” simulation to test rules before enabling
Optional Redis — PostgreSQL-based alternatives with adapter pattern queue system
Fixed
Log Context modal reopening after close
Exception details from metadata display
WebSocket memory leak in live tail handler
SQL injection prevention in notification publisher
v0.4.2
Released
Fix
Clipboard Utility & Config Validation
Centralized clipboard copy function, config validation test coverage, and documentation corrections.
Centralized clipboard utility
Config validation test coverage
Show detailsHide details
Added
Clipboard utility with centralized copy function
Config validation test coverage
Fixed
Documentation corrections for API configuration
Docker Compose configuration information
v0.4.1
Released
Improvement
Multi-Language Exception Parsers
Stack trace parsing support for multiple programming languages and dependency updates including @sveltejs/kit.
Multi-language stack trace parsing
SvelteKit and dependency updates
Show detailsHide details
Added
Exception parsers for multi-language stack trace parsing
Changed
Dependencies updated including @sveltejs/kit
Fixed
OTLP endpoint URLs in documentation
v0.4.0
Released
Breaking
LogWard to LogTide Rebrand & Search
Project rebranding from LogWard to LogTide, substring search with trigram indexes, clickable dashboard elements, exception visualization, and customizable retention.
Project rebranded from LogWard to LogTide
Substring search with PostgreSQL trigram index
Clickable dashboard elements
Enhanced exception & stack trace visualization
Customizable log retention policy
Show detailsHide details
Added
Substring Search — Full-text search with PostgreSQL trigram index
Clickable Dashboard Elements — Interactive navigation from charts and widgets
Customizable Log Retention — Configurable retention policies per project
Full-Page Export — Export all matching logs across pages
Custom Time Range Picker — Improved date/time selection UI
Breaking Changes
Environment variables renamed (LOGWARD → LOGTIDE)
Fluent Bit configuration variables renamed
Database defaults changed
Docker container and service names changed
SMTP default sender changed
Fixed
Mobile navigation menu hamburger functionality
Services dropdown showing all services
Journald log format detection
Syslog level mapping improvements
OTLP protobuf parsing with proper binary support
v0.3.3
Released
Feature
LDAP, OIDC & Auth-Free Mode
Enterprise authentication with LDAP and OpenID Connect, auth-free mode for home labs, initial admin via environment variables, and ARM64 Docker builds.
Syslog integration documentation with device-specific guides, Go SDK documentation, and documentation restructure with new Integrations section.
Syslog integration with device guides
Go SDK documentation
Documentation restructure
Runtime PUBLIC_API_URL configuration
Show detailsHide details
Added
Syslog integration documentation with device-specific guides
Go SDK documentation at /docs/sdks/go
Documentation restructure with new Integrations section
Changed
Docker Compose improved container orchestration
Onboarding flow skip behavior refinement
Runtime configuration for PUBLIC_API_URL
Fixed
Sign Up Free link pointing to correct route
Skip tutorial redirect loop
API URL in code examples
v0.2.3
Released
Improvement
Docker Image Publishing & Self-Hosting Docs
Automated Docker image publishing via GitHub Actions CI/CD and comprehensive self-hosting deployment documentation.
Docker images via GitHub Actions CI/CD
Self-hosting deployment documentation
Pre-built images in docker-compose.yml
Show detailsHide details
Added
Docker image publishing with GitHub Actions CI/CD pipeline
Self-hosting documentation with deployment guides
Changed
docker-compose.yml now uses pre-built images by default
v0.2.2
Released
Feature
Onboarding Tutorial & Empty States
Multi-step onboarding wizard with progress tracking, empty state components for guidance, and expanded testing infrastructure.
Multi-step onboarding wizard
User onboarding checklist with progress
Empty state guidance components
Expanded testing infrastructure
Show detailsHide details
Added
Onboarding Tutorial — Multi-step wizard guiding new users through setup
Empty State Components — Helpful guidance when dashboards have no data yet
User Onboarding Checklist — Progress tracking for initial setup steps
UI enhancements with help components
Fixed
Organization context handling improvements
Error states and loading indicators
v0.2.1
Released
Improvement
Redis Caching & 50x Performance Boost
Type-safe Redis caching layer with session validation 30x faster, API key verification 20x faster, query results 10x faster, and aggregations 50x faster.
Session validation 30x faster
API key verification 20x faster
Query results 10x faster
Aggregations 50x faster
Show detailsHide details
Added
Redis Caching Layer — Type-safe cache keys with automatic invalidation
Landing page public index
Changed
Database optimization with composite indexes
Performance
Session validation 30x faster with caching
API key verification 20x faster
Query results 10x faster
Aggregations 50x faster
Fixed
Admin panel double sidebar issue
Admin routes navigation path corrections
v0.2.0
Released
Feature
OpenTelemetry & Distributed Tracing
Full OpenTelemetry support with OTLP endpoints, distributed tracing with complete CRUD operations, and 563+ tests.
OpenTelemetry OTLP endpoints
Distributed tracing with full CRUD
563+ tests
Keyboard navigation for span selection
Show detailsHide details
Added
OpenTelemetry Support — OTLP ingestion endpoints for traces and logs
Distributed Tracing — Full CRUD operations with waterfall visualization
Testing Infrastructure — 563+ tests covering all major features
Changed
OTLP ingestion performance optimization
Span selection UX with keyboard navigation
Fixed
Frontend UX issues in OTLP data display
trace_id handling flexibility for various formats
v0.1.0
Released
Feature
Initial Release
First public release of LogTide with multi-organization architecture, batch log ingestion, real-time streaming, TimescaleDB storage, Sigma detection engine, and official SDKs.
Multi-organization architecture
High-performance batch log ingestion
Real-time log streaming via SSE
Sigma detection engine
Node.js, Python, PHP & Kotlin SDKs
Docker Compose deployment
Show detailsHide details
Added
Multi-organization architecture — Isolated workspaces for teams and projects
High-performance batch log ingestion — Optimized for high throughput
Real-time log streaming — Server-Sent Events for live tail
Advanced search and filtering — Query logs with complex filters
TimescaleDB storage — Automatic compression and retention policies
Dashboard with statistics — Overview of log volume, errors, and trends
Alert system — Rules with email and webhook notifications