diff --git a/CHANGELOG.md b/CHANGELOG.md
index f9007e99..db2ef8a2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,56 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [1.2.0] - 2026-07-23
+
+### Security
+- **Bumped echarts to 6.1.0** (Dependabot `GHSA-fgmj-fm8m-jvvx` / `CVE-2026-45249`). Versions before 6.1.0 render a raw HTML `series.data[i].name` through an `innerHTML` sink into the built-in tooltip of a Lines series when no custom `tooltip.formatter` is set, bypassing the automatic HTML escaping the built-in formatters otherwise apply and allowing script execution. The frontend does not use the Lines series, so LogTide was not exposed in practice, but the dependency is upgraded to clear the advisory and prevent future exposure.
+- **Bumped `protobufjs` to 7.6.5** (Dependabot `GHSA-j3f2-48v5-ccww`, MODERATE): versions `>=7.5.0 <=7.6.4` (and the 8.x line up to 8.6.5) can enter an infinite loop while parsing `.proto` option syntax, hanging the thread (DoS). LogTide loads OTLP protobuf via `protobufjs` in the backend ingestion path; it never parses untrusted `.proto` text at runtime, so real exposure was limited, but the root pnpm override floor was raised from `>=7.6.3 <8` to `>=7.6.5 <8` to clear the advisory. Stays on the 7.x line (the `<8` bound is intentional).
+- **Bumped `brace-expansion` to 5.0.7** (Dependabot `GHSA-3jxr-9vmj-r5cp` / `CVE-2026-13149`, HIGH): versions `>=3.0.0 <5.0.7` expand consecutive non-expanding `{}` groups in exponential time, so a short all-ASCII input (~90 bytes / 30 groups) can block the calling thread for minutes (DoS). Pulled in transitively (via glob/minimatch in tooling); the root pnpm override floor was raised from `>=5.0.6` to `>=5.0.7`.
+
+### Changed
+- **Login failures classified by typed error code, not message strings** (auth provider follow-up): `authenticationService.authenticateWithProvider` now throws a typed `AuthError` carrying the provider's `AuthErrorCode` instead of a plain `Error` built from the user-facing message, and `POST /auth/login` maps that code to the HTTP status and audit reason. Previously the route matched exact message strings, so rewording any of them would have silently turned a wrong-password 401 into a 500 and dropped its `auth.login_failed` audit row. A login while the local provider is disabled or removed now returns a clean `503` (reason `provider_unavailable`) instead of a raw 500. A dedicated `AuthErrorCode.SSO_REQUIRED` distinguishes the SSO-only account case from a wrong password so the audit reason is derived from the code. The HTTP response body is unchanged (still the provider message), so anti-enumeration behavior is untouched. The redundant `usersService.login` (a parallel local-auth path with no production callers that never received the enumeration hardening) was removed and its tests consolidated onto the provider path and the shared `createTestSession` helper.
+- **Local login routed through the auth provider registry** (#266, community contribution by @Justy116): `POST /auth/login` now authenticates via the same provider path used by OIDC and LDAP instead of the parallel `usersService.login` implementation, and registration auto-login rides that path too. If the auto-login after signup fails transiently, the endpoint returns 201 with the created user and no `session` (the account stays recoverable via a normal login) instead of propagating a 500 after the user row is already committed. The local provider also gained user-enumeration hardening: the password is verified before the disabled-account check, so a wrong password returns the same generic error whether or not the account is disabled, and only a correct password reveals the disabled state. New `docs/architecture/auth.md` documents the provider architecture. Follow-up fixes landed with the merge: the register page now handles the sessionless 201 (redirects to login with a "please sign in" toast instead of throwing), the disabled-account and SSO-required login rejections are now recorded as `auth.login_failed` audit events with a `reason` in the metadata (previously only wrong-password failures were audited; the reason never appears in the HTTP response, so anti-enumeration behavior is unchanged), and a failed post-registration auto-login is now logged through internal observability instead of being swallowed silently. Test-suite hardening that the registry routing made necessary: two auth test files deleted every `auth_providers` row including the migration-seeded `local` provider, which (now that login resolves through the registry) broke every login-based test that ran after them, masked intermittently by the registry's 5-minute cache; they now preserve the seeded row and the test setup re-seeds it defensively, since tracked migrations never re-run
+
+### Fixed
+- **Persisted time-range / current-project stores keep their value when localStorage is blocked** (pre-release hardening): `timeRangeStore.get()` and `currentProjectStore.get()` re-read localStorage on every call, so in a session where localStorage is unavailable (private mode, disabled, quota) a `set()` still updated subscribers but `get()` kept returning null, breaking the intended cross-page persistence. Both stores now hold an in-memory source of truth (hydrated from localStorage once) that `get()` returns, so a selection survives within the session regardless of localStorage.
+- **Digest dispatch passes `last_sent_at` through as a Date** (pre-release hardening): the hourly dispatch cast the already-`Date` column to `string` and re-parsed it (`new Date(x as unknown as string)`), a needless round-trip that risked locale-dependent parsing on the double-fire guard. It now passes the Date straight through.
+- **Error-group merge is now scoped to a single project** (pre-release hardening of the merge/duplicate feature added in this release): error groups are unique per `(organization_id, project_id, fingerprint)`, so the same exception type/message/fingerprint legitimately exists in sibling projects of one org. `findDuplicateErrorGroups`, `mergeErrorGroups` and the exception-retag step scoped only by `organization_id`, so the duplicate list could surface another project's group, a merge could delete it, and the retag (an `UPDATE exceptions SET fingerprint=... WHERE organization_id=? AND fingerprint IN (...)`) could rewrite a sibling project's exceptions that shared the same fingerprint string, corrupting them. All three are now scoped to the target group's `project_id` (null-safe). The `merge_key` backfill in migration 055 also now prefers the source-mapped frame (`original_file`/`original_function`), matching `FingerprintService.topAppFrame()`, so backfilled keys line up with the keys the runtime computes for new occurrences. Cross-project isolation regression tests added.
+- **A bad client-side telemetry key logged the user out on every page view** (UX audit): the global 401 interceptor treated a 401 from any `/api/v1/` request (other than `/auth/`) as an expired session and bounced the user to `/login`. But ingestion/telemetry endpoints (`/api/v1/ingest`, `/v1/otlp/*`, inbound `/api/v1/receivers/*`) authenticate with an API key or DSN, not the session, so a 401 there means a bad telemetry key, not an expired session. The client-side SDK (`hooks.client.ts`) captures a log on every navigation; if its DSN key is wrong or revoked, each page view returned 401 and the interceptor logged the user out in a loop. Those endpoints are now excluded from the session-expiry logout.
+- **Silent PII rule toggles** (UX audit): toggling a PII masking rule on/off or changing its action (mask/redact/hash) updated the rule but showed no confirmation, unlike every other action in that page (create/update/delete all toast). Both now show a success toast, matching the rest of the app's consistent action feedback.
+- **Inconsistent time-range selectors across pages** (UX audit): the preset time-range controls on Security, Usage and Metrics were each hand-rolled with different markup (button groups and a dropdown with different styling). They now share one `TimeRangeButtons` segmented component, so they look and behave identically. Logs and Traces keep the richer time-range popover (they support custom ranges); the preset-only pages are now visually consistent.
+- **"Total Logs Today" and "Error Rate" collapsed to the last hour** (UX audit): the dashboard stats (project overview cards and custom-dashboard single-stat panels, both via `dashboardService.getStats`) read "today" from the hourly continuous aggregate for everything but the last hour, backfilling only the last hour from raw. When the aggregate is not kept warm (a refresh policy that is not running, seeded/backfilled data, or a fresh install), the older part of today is missing from it, so the total and the error rate silently collapsed to roughly the last hour. Verified live on the hosted demo: 24h = ~1,500 logs, last hour = ~67, yet "Total Logs Today" read ~60 and Error Rate 0.0%. Low-volume organizations now compute today/yesterday from an exact raw count (cheap over the bounded window and independent of aggregate health); high-volume instances keep the fast aggregate path, where live ingestion keeps the aggregate current.
+- **The same error split into several error groups** (UX audit): Node.js runtime frames use the `node:` scheme (e.g. `node:internal/process/task_queues`), which was not in the exception detector's library-pattern list, so `isAppCode` classified them as application code. Async stack traces vary in which internal frames they carry, so those frames leaked into the stack fingerprint and the same logical error (observed live: one `TypeError: Cannot read properties of undefined (reading 'selectFrom')` at `products/routes.js:130` fanned out into 5+ groups of 60/48/46/1/1 occurrences). `node:` frames are now treated as library code: excluded from the fingerprint (so recurrences of one error collapse into a single group) and no longer shown under "Show App Code Only". Fingerprints for errors whose stacks included `node:` frames change, so existing groups re-consolidate going forward rather than retroactively. Regression tests added.
+- **Sidebar flashed "Select organization" on a hard reload** (UX audit): the organization store only persisted the selected org's id and started with `currentOrganization: null`, so a full page load of any dashboard sub-route rendered "Select organization" in the sidebar until the org list finished fetching. The store now caches the whole selected organization object and hydrates its initial state from it, so the org name renders immediately and is reconciled with the authoritative list once it loads (falling back cleanly if the cached org no longer exists). `clear()` was also fixed to reset to an empty state instead of the module-load initial state, so logout no longer restores the cached org.
+- **Project overview stat cards showed wrong trend units and a mislabeled window** (UX audit): the shared `StatsCard` hardcoded every trend as "N% from last period", so the "Active Services" card, whose trend is an absolute count delta (services today minus yesterday), rendered a plain count with a bogus "%" ("+1.00% from last period" for one new service). The card now takes an optional trend `unit` and `label`: Active Services shows "+1 vs yesterday", Error Rate shows its change in percentage points ("pp"), and Total Logs/Throughput keep percentages with accurate "from yesterday"/"from last hour" labels. Separately, the Error Rate card described itself as "last 24h" while the value it shows is today's rate (since local midnight); the description now reads "Error rate today" to match the number.
+- **Error groups always showed "unknown" affected service on ClickHouse and MongoDB** (UX audit): the `error_groups` service attribution was computed by a Postgres trigger that resolved the service with `SELECT service FROM logs WHERE id = NEW.log_id`. On non-TimescaleDB reservoir backends the ingested logs never land in the Postgres `logs` table (there is deliberately no foreign key on `exceptions.log_id`, "no FK due to hypertable"), so the lookup returned NULL and every error group was attributed to `unknown`, regardless of the real emitting service. The ingestion path already knows the service (`log.service`), so it is now carried on the `exceptions` row (new nullable `service` column, migration 054) and the trigger prefers it (`NEW.service`), falling back to the logs lookup on TimescaleDB and only then to `unknown`. New occurrences also merge the real service into existing groups via the ON CONFLICT path, so groups previously stuck on `unknown` recover as they recur. Multi-engine regression tests added.
+- **Monitoring page used unstyled native `` dropdowns** (UX audit): the project picker, New Monitor project/type selectors, incident severity/status selectors, and the status-page visibility selector all rendered the browser's native ``, which looked out of place next to the custom dropdowns used everywhere else in the app. All six now use the shared `Select` component, so styling, focus, and open/close behavior match the rest of LogTide.
+- **Metrics Explorer showed duplicate project and time-range selectors** (UX audit): the Explorer tab's "Filters" card repeated the project and time-range selectors that already live in the always-visible page header (both bound to the same state), so the tab presented two of each control at once. The Filters card now keeps only the Explorer-specific metric selector; project and time range are driven solely by the header controls.
+- **Projects page browser tab title said "Dashboard"** (UX audit): the `/dashboard/projects` list page hardcoded `Dashboard - LogTide `, so the browser tab and history entry read "Dashboard" instead of "Projects". Now titled "Projects - LogTide", matching every sibling page.
+- **Members "Joined" column mixed absolute and relative dates** (UX audit): the settings members table used a local formatter that showed "Today"/"Yesterday"/"N days ago" for recent joins but flipped to an absolute `M/D/YYYY` date after a week, so the column mixed two formats side by side. It now shows a single consistent short date ("Apr 12, 2026") with the relative time in a hover tooltip, via a shared `formatDateShort` helper. The bespoke local formatter was removed.
+- **Ingestion health counters no longer depend on the metering toggle** (#279). `METERING_ENABLED=false` silently suppressed every `ingestion.*` counter, including `ingestion.pii_rejected`, the safety counter for records dropped because PII masking failed. That toggle governs usage and resource metering, while the `ingestion.*` counters are operational health signals already excluded from tenant usage breakdowns by a prefix filter, so they now bypass it. The buffer hard cap still applies to them: that is a memory valve, not a feature switch.
+
+### Added
+- **Custom time range now uses a calendar with explicit time inputs** (UX audit): the log/trace time-range picker's "Custom" option replaced the two native `datetime-local` inputs (an inconsistent, browser-styled widget) with a proper in-app range calendar (new shadcn-style `RangeCalendar` wrapping bits-ui, so it matches the rest of the UI) plus dedicated "From time" / "To time" inputs for choosing the hour. The stored value stays `YYYY-MM-DDTHH:mm`, so URL sync, the shared time-range store, and deep links are unchanged. Adds the `@internationalized/date` dependency (already used by bits-ui).
+- **Auto-merge duplicate error groups at ingestion**: builds on the manual merge below so new occurrences stop creating duplicates in the first place. Alongside the exact stack fingerprint, each group now has a coarser `merge_key` (exception type + normalized message + the top application frame). When an incoming exception has no exact fingerprint match but shares a `merge_key` with an existing group in the same project, it folds into that group instead of spawning a duplicate; errors thrown from different sites, or with different messages once dynamic tokens (numbers, UUIDs, hex, emails, quoted strings) are masked, stay separate. The key is computed by a single Postgres function `logtide_merge_key` used by the trigger, the runtime fold lookup, and a one-time backfill of existing groups (migration 055), so there is no SQL/JS normalization to keep in sync; library-only exceptions (no app frame) get a null key and are never auto-merged. The manual "Merge N" banner remains for historical splits. DB-backed tests cover fold, throw-site separation, dynamic-message folding, and the library-only null case.
+- **The time window is remembered across logs and traces** (UX audit): picking a range (or a custom window) on Logs now carries over to Traces and vice versa, via a shared `timeRange` store persisted in localStorage. Each page validates the stored preset against what it supports and falls back to its default otherwise; deep links with explicit from/to still win. Metrics/Security/Usage keep their own preset vocabularies.
+- **The selected project is remembered across pages** (UX audit): Metrics, Traces and Monitoring each defaulted to "the first project" independently, so moving between them kept resetting which project you were looking at. A shared `currentProject` store now persists the last project you selected (localStorage) and those pages default to it when it is still valid, falling back to the first project otherwise. Deep links with an explicit project still win.
+- **Recent searches in log search** (UX audit): the log search page remembers your recent query terms (in localStorage) and shows them as one-click chips under the search bar when the box is empty, with a Clear action. Capped at the last 8, deduplicated.
+- **Dashboard auto-refresh** (UX audit): the custom dashboard header has an auto-refresh control (off / 30s / 1m / 5m) that periodically refreshes all panels of the active dashboard. The choice persists in localStorage and pauses automatically while editing the layout so a refresh cannot clobber unsaved edits.
+- **Row density toggle in log search** (UX audit): a Cozy/Compact toggle in the table toolbar (styled as an outline button to match the neighbouring Columns control) tightens row padding for scanning more logs at once, persisted in localStorage. (Custom columns were already persisted per project.)
+- **Error regression detection** (UX audit): when an error group that was marked resolved starts occurring again, it is now automatically reopened (status back to open, resolution cleared) and its notification is flagged as a regression, with distinct wording across the in-app notification ("Regression: ..."), the email subject ("[Regression] ..."), and the webhook envelope (high severity plus an `is_regression` field). Previously a resolved error could recur silently and stay hidden in the resolved bucket. Job + wording covered by tests.
+- **Merge duplicate error groups** (UX audit): complements the fingerprint fix above for groups that already split before it landed. The error detail page detects other groups with the same exception type and message and offers a one-click "Merge N" that folds them into the current group: their exceptions are reassigned to the target fingerprint (so trend and logs stay complete), occurrence counts and affected services are summed/unioned, and first/last-seen are widened, all in one org-scoped transaction. New `GET /api/v1/error-groups/:id/duplicates` and `POST /api/v1/error-groups/:id/merge` (membership-checked, cross-org merges refused). Service + org-isolation tests added.
+- **Shareable log search URLs** (UX audit): the log search page now writes its active filters back to the URL (query text, project, service, levels, trace/session id, and a custom time range), so a search can be bookmarked or sent to a teammate and reopened exactly as filtered. It reuses the existing (idempotent) deep-link reader, uses `replaceState` so it does not spam history, and skips the write when nothing changed. (The errors page already synced its filters; traces already accepts deep links.)
+- **Command palette searches your data, not just navigation** (UX audit): typing in the palette now offers "Search logs for ...", "Search errors for ...", and, when the text looks like a 16/32-char hex trace id, "Open trace ...". The log search page learned a `q` query param and the traces page learned a `traceId` param so these deep links land pre-filled.
+- **Duplicate monitors and alert rules** (UX audit): the monitoring list has a Duplicate action that opens the New Monitor form pre-filled from an existing monitor (name suffixed "(copy)"), and the alerts list has a Duplicate action that opens the builder pre-filled with the full rule (service, levels, threshold, time window, rate-of-change settings, metadata filters and notification channels). The alert builder's prefill was generalized into a single `AlertBuilderPrefill` object shared by duplicate and create-from-error.
+- **"Create alert" from an error group** (UX audit): the error detail page has a Create alert button that opens the alert builder prefilled from the error, the name seeded from the exception type, levels set to error/critical, and the service filled in when the group is attributed to a real service. The builder accepts optional prefill props and the alerts page reads them from query params (then strips them so a refresh does not reopen the dialog).
+- **Metrics empty state now shows copy-ready OTLP setup** (UX audit): the "No metrics found" state was a bare icon and one line. It now mirrors the traces empty state with a proper onboarding panel: Node.js/Python/Go OpenTelemetry metric-exporter snippets (pointing at `/v1/otlp/metrics`) with copy buttons, quick links to get an API key and the OTLP/OpenTelemetry docs, and a preview of what metrics unlock.
+- **Theme "System" option** (UX audit): the theme control is now a Light / Dark / System menu instead of a two-state toggle. "System" follows the OS color scheme and tracks it live (a `prefers-color-scheme` change flips the app without a reload). The stored preference persists across sessions; existing consumers still read the resolved light/dark theme, and the previous `toggle()`/`set()` helpers keep working.
+- **Search term highlighting and relative-time tooltips in log search** (UX audit): in full-text mode the matched query terms are now highlighted in the message column (rendered as plain text segments, never `{@html}`, so no markup injection), and each log's absolute timestamp carries a relative-time tooltip ("3 minutes ago") on hover.
+- **Clock skew detection for spans and metrics** (follows #279). Extends the log-side detection to OTLP span and metric ingestion: a span whose `end_time`, or a metric data point whose `time`, sits more than 24 hours in the past or more than 5 minutes ahead of the server clock is counted and surfaced on the project overview, never rejected. Spans are observed on `end_time` rather than `start_time` on purpose: a span that STARTED 27 hours ago is routinely legitimate, since spans are exported at end and batch jobs, long transactions and long-poll connections all produce old start times. Clock skew shifts both ends together while a long span moves only its start, so `end_time` is what separates a broken clock from a slow operation. Unlike the log threshold, which is exactly the maximum alert `time_window` and therefore an exact statement, the span and metric thresholds are a judgement call: no alert rules run over them, and the harm is that the data is invisible in the time windows of trace views and dashboards. Counters are `ingestion.span_timestamp_skew` and `ingestion.metric_timestamp_skew`, kept under the `ingestion.` prefix because both the tenant usage-breakdown exclusion and the metering-toggle bypass key off it literally. The project ingestion health endpoint now reports skew per signal, and the banner carries per-signal copy: the log wording ("cannot trigger an alert", "omit the time field") is false for spans and metrics.
+- **Ingestion clock skew detection** (#279). Logs whose client-supplied `time` is more than 24 hours in the past or more than 5 minutes ahead of the server clock are counted and surfaced, on the project overview page for tenants and in admin ingestion health for operators. Such logs are stored and searchable but can never be counted by a threshold alert rule, whose largest window is 24 hours, which previously made a misconfigured shipper look like an alerting bug: ingestion worked, rules were enabled, channels tested fine, and no alert ever fired. The 24 hour threshold is derived rather than chosen, being the maximum configurable alert `time_window`. The logs are never rejected, so backfilling historical data stays valid, and detection is always on with no configuration to get wrong. Detection runs inside the existing record-building pass in `ingestLogs`, the single choke point for every log path (batch, Fluent Bit, OTLP, receivers), at a cost of one subtraction per record.
+- **Scheduled email digest reports, completed and enabled** (#154): the digest feature whose foundation landed in #209 (and shipped disabled since 1.0.0-beta) is now finished and active. The report grew from log volume only to five sections, each with a quiet empty state: log volume with period-over-period trend, top 5 services by error+critical count with delta vs the previous period (engine-agnostic via `reservoir.topValues`, so TimescaleDB/ClickHouse/MongoDB all work), new error groups first seen in the period (`error_groups.first_seen`), a security summary (windowed detection totals and top triggered Sigma rules read from raw `detection_events` to avoid continuous-aggregate staleness, plus a point-in-time open/investigating incident count), and monitor uptime (aggregated from `monitor_uptime_daily`, section omitted for orgs without monitors). Emails are now real HTML built on the shared `email-templates.ts` component library (inline CSS, escaped user-controlled strings, en-US number formatting) with the full report duplicated in the plaintext fallback, and the base template footer learned an `unsubscribeUrl` variant so digest recipients (who may have no account) get a one-click unsubscribe instead of the channel-settings link. Scheduling was redesigned: instead of one cron per organization registered at worker boot (which could never pick up config changes at runtime, because graphile-worker cron items are fixed once the runner starts), a single static hourly `digest-dispatch` cron sweeps enabled `digest_configs`, matches `delivery_hour`/`delivery_day_of_week` against the current UTC hour, and enqueues `digest-generation` jobs with hour-keyed dedup keys and a `last_sent_at` double-fire guard (migration 053), so config CRUD is live on both queue backends with no restart. New management surface: session-auth CRUD under `/api/v1/digests` (config upsert/delete, recipient add/remove/resubscribe with token rotation on resubscribe, membership for reads and owner/admin for writes, audit-logged as `digest.*`, recipients capped by a new `digests.max_recipients` capability with the canonical 4-case tests), a public `POST /api/v1/digests/unsubscribe` where the 32-byte random token is the credential (idempotent, returns a masked email), an "Email Digests" settings page (schedule card with UTC delivery time and recipient management) and a public `/unsubscribe` page consumed by the email footer link. The worker-side disable comment from the beta is gone: the dispatch cron registers at boot
+
## [1.1.0] - 2026-07-08
### Added
diff --git a/README.md b/README.md
index 286512d1..dc7b56d3 100644
--- a/README.md
+++ b/README.md
@@ -16,14 +16,14 @@
-
+
-> **🌊 LogTide 1.1.0 (public beta):** unified **Logs, Traces & Metrics** with a built-in **SIEM**, multi-engine storage (TimescaleDB / ClickHouse / MongoDB), uptime monitoring, parsing pipelines, and custom dashboards.
+> **🌊 LogTide 1.2.0 (public beta):** unified **Logs, Traces & Metrics** with a built-in **SIEM**, multi-engine storage (TimescaleDB / ClickHouse / MongoDB), uptime monitoring, parsing pipelines, and custom dashboards.
---
@@ -124,7 +124,7 @@ We host it for you. Perfect for testing. [**Sign up at logtide.dev**](https://lo
---
-## ✨ Core Features (v1.1.0)
+## ✨ Core Features (v1.2.0)
### Monitoring, Pipelines & Dashboards
* 🩺 **Uptime Monitoring & Status Pages:** HTTP/TCP/heartbeat monitors with configurable thresholds, auto-created SIEM incidents on failure, scheduled maintenances, and public Uptime-Kuma-style status pages per project.
diff --git a/docs/architecture/auth.md b/docs/architecture/auth.md
new file mode 100644
index 00000000..f48da0a8
--- /dev/null
+++ b/docs/architecture/auth.md
@@ -0,0 +1,417 @@
+# Authentication Architecture
+
+Logtide's authentication system is built around an `AuthProvider` interface. Every authentication
+method — local email/password, OIDC, LDAP — is an implementation of this interface. The core
+login path, session layer, and user-management code are unaware of *how* a user authenticated;
+they only care about the resulting `userId`.
+
+---
+
+## Table of Contents
+
+1. [Core Concepts](#core-concepts)
+2. [The `AuthProvider` Interface](#the-authprovider-interface)
+3. [Provider Registry](#provider-registry)
+4. [Database Schema](#database-schema)
+5. [Session Model](#session-model)
+6. [Built-in Providers](#built-in-providers)
+7. [Writing a Custom Provider](#writing-a-custom-provider)
+8. [Capability Gate](#capability-gate)
+9. [What Is Not Abstracted](#what-is-not-abstracted)
+
+---
+
+## Core Concepts
+
+| Concept | Location | Role |
+|---------|----------|------|
+| `AuthProvider` interface | `src/modules/auth/providers/types.ts` | Contract every provider must satisfy |
+| `ProviderRegistry` | `src/modules/auth/providers/registry.ts` | Singleton that loads/caches provider instances |
+| `AuthenticationService` | `src/modules/auth/authentication-service.ts` | Orchestrates provider delegation, user provisioning, session creation |
+| `auth_providers` table | `migrations/010_auth_providers.sql` | Persistent provider configuration (type, slug, JSONB config) |
+| `user_identities` table | `migrations/010_auth_providers.sql` | Maps each user to one or more provider identities |
+
+The flow for every login is:
+
+```
+HTTP route → AuthenticationService.authenticateWithProvider(slug, credentials)
+ └─ ProviderRegistry.getProvider(slug)
+ └─ provider.authenticate(credentials)
+ └─ findOrCreateUser(provider, result) ← user provisioning lives here
+ └─ createSession(userId) ← opaque token, provider-agnostic
+```
+
+---
+
+## The `AuthProvider` Interface
+
+```typescript
+// src/modules/auth/providers/types.ts
+
+export interface AuthProvider {
+ /** Provider type: 'local' | 'oidc' | 'ldap' (or your custom string) */
+ readonly type: AuthProviderType;
+
+ /** Full provider configuration row from auth_providers table */
+ readonly config: AuthProviderConfig;
+
+ /**
+ * Authenticate a user.
+ * - Local: receives { email, password }
+ * - LDAP: receives { username, password }
+ * - OIDC: not used (uses callback flow instead)
+ * - Custom: whatever shape you define
+ */
+ authenticate(credentials: unknown): Promise;
+
+ /** True for redirect-based flows (OIDC). False for credential-based flows. */
+ supportsRedirect(): boolean;
+
+ /** OIDC only: generate authorization URL + PKCE state. */
+ getAuthorizationUrl?(redirectUri: string): Promise;
+
+ /** OIDC only: handle provider callback and exchange code for tokens. */
+ handleCallback?(
+ data: OidcCallbackData,
+ expectedNonce: string
+ ): Promise;
+
+ /** Validate that config is structurally correct (called before saving). */
+ validateConfig(): boolean;
+
+ /** Optional: test the external connection (shown in admin UI). */
+ testConnection?(): Promise<{ success: boolean; message: string }>;
+}
+```
+
+`AuthenticationResult` carries the information `AuthenticationService` needs to look up or create
+a user:
+
+```typescript
+interface AuthenticationResult {
+ success: boolean;
+ providerUserId?: string; // stable external ID (OIDC 'sub', LDAP DN, email for local)
+ email?: string;
+ emailVerified?: boolean; // gates auto-linking; set to true only when provider asserts it
+ name?: string;
+ metadata?: Record;
+ error?: string;
+ errorCode?: AuthErrorCode;
+}
+```
+
+---
+
+## Provider Registry
+
+`ProviderRegistry` is a singleton (`providerRegistry`) that:
+
+1. Reads rows from `auth_providers` on first use (lazy init).
+2. Creates provider instances via `createProvider(config)` — a factory switch on `config.type`.
+3. Caches configs in Redis (5-minute TTL) and instances in memory.
+4. Exposes `invalidateCache()` so the admin API can trigger a reload after config changes.
+
+```typescript
+// src/modules/auth/providers/registry.ts (simplified)
+
+function createProvider(config: AuthProviderConfig): AuthProvider {
+ switch (config.type) {
+ case 'local': return new LocalProvider(config);
+ case 'oidc': return new OidcProvider(config);
+ case 'ldap': return new LdapProvider(config);
+ default: throw new Error(`Unknown provider type: ${config.type}`);
+ }
+}
+```
+
+To add a new built-in provider type, add a `case` here and ship the class alongside it. Provider
+configs are stored in the database, so no code change is needed to *configure* existing types at
+runtime — only to add a new type.
+
+---
+
+## Database Schema
+
+```sql
+-- auth_providers: one row per configured provider instance
+CREATE TABLE auth_providers (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ type VARCHAR(50) NOT NULL, -- 'local' | 'oidc' | 'ldap'
+ name VARCHAR(255) NOT NULL, -- display name
+ slug VARCHAR(255) NOT NULL UNIQUE, -- used in API routes: /auth/providers/:slug/...
+ enabled BOOLEAN NOT NULL DEFAULT true,
+ is_default BOOLEAN NOT NULL DEFAULT false, -- shown prominently on login page
+ display_order INT NOT NULL DEFAULT 0,
+ icon VARCHAR(100),
+ config JSONB NOT NULL DEFAULT '{}', -- provider-specific config (issuer URL, etc.)
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+-- user_identities: maps a user to an external identity
+-- one user can have N identities (local + OIDC + LDAP simultaneously)
+CREATE TABLE user_identities (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ provider_id UUID NOT NULL REFERENCES auth_providers(id) ON DELETE CASCADE,
+ provider_user_id VARCHAR(500) NOT NULL, -- OIDC 'sub', LDAP DN, email for local
+ metadata JSONB, -- cached claims / attributes
+ last_login_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ UNIQUE (provider_id, provider_user_id)
+);
+```
+
+Migration `010_auth_providers.sql` seeds a `local` row in `auth_providers` and backfills
+`user_identities` for every existing user (using `email` as `provider_user_id`).
+
+---
+
+## Session Model
+
+Sessions are not changed by the provider abstraction. After any successful authentication:
+
+1. `AuthenticationService` calls `usersService.createSession(userId)`.
+2. A row is inserted into `sessions` with an opaque 32-byte random token.
+3. The token is returned to the client as a Bearer token.
+
+Sessions carry only `userId` and `expiresAt`. The provider that was used to authenticate is not
+stored on the session. This means:
+
+- A user who logs in via OIDC gets exactly the same session as one who logs in locally.
+- Provider-specific tokens (OIDC `access_token`, LDAP bind credentials) are **not** kept in the
+ session. They are consumed only during the authentication handshake.
+- Revoking a provider (disabling its row in `auth_providers`) does not automatically expire
+ existing sessions. User management (disabling a user) invalidates sessions because session
+ validation checks `users.disabled`.
+
+---
+
+## Built-in Providers
+
+| Type | Class | Credential shape | Redirect? |
+|------|-------|-----------------|-----------|
+| `local` | `LocalProvider` | `{ email, password }` | No |
+| `oidc` | `OidcProvider` | N/A (callback flow) | Yes |
+| `ldap` | `LdapProvider` | `{ username, password }` | No |
+
+Operators add provider instances via the admin UI (`/admin/auth/providers`) or the admin API
+(`POST /api/v1/admin/auth/providers`). Multiple instances of the same type are supported (e.g.,
+two separate OIDC providers for different identity platforms).
+
+---
+
+## Writing a Custom Provider
+
+Below is a complete, minimal example: a **magic-link** provider that authenticates users by
+verifying a short-lived token passed as a credential. It demonstrates the minimum required to
+satisfy the `AuthProvider` interface.
+
+### 1. Define the credential shape and config
+
+```typescript
+// src/modules/auth/providers/magic-link-provider.ts
+
+import type {
+ AuthProvider,
+ AuthProviderConfig,
+ AuthenticationResult,
+} from './types.js';
+import { AuthErrorCode } from './types.js';
+import { CacheManager } from '../../../utils/cache.js';
+import { db } from '../../../database/connection.js';
+
+interface MagicLinkCredentials {
+ token: string; // the one-time token the user received by email
+}
+
+interface MagicLinkConfig {
+ tokenTtlSeconds?: number; // stored in auth_providers.config
+}
+
+export class MagicLinkProvider implements AuthProvider {
+ readonly type = 'magic_link' as const;
+ readonly config: AuthProviderConfig;
+
+ private get providerConfig(): MagicLinkConfig {
+ return this.config.config as MagicLinkConfig;
+ }
+
+ constructor(config: AuthProviderConfig) {
+ this.config = config;
+ }
+
+ async authenticate(credentials: unknown): Promise {
+ const { token } = credentials as MagicLinkCredentials;
+
+ if (!token) {
+ return {
+ success: false,
+ error: 'Token is required',
+ errorCode: AuthErrorCode.INVALID_CREDENTIALS,
+ };
+ }
+
+ // Tokens are stored in Redis as: magic_link: → email
+ const cacheKey = `magic_link:${token}`;
+ const email = await CacheManager.get(cacheKey);
+
+ if (!email) {
+ return {
+ success: false,
+ error: 'Token is invalid or has expired',
+ errorCode: AuthErrorCode.INVALID_CREDENTIALS,
+ };
+ }
+
+ // One-time use: delete immediately after successful validation
+ await CacheManager.delete(cacheKey);
+
+ // Look up the user (magic-link only works for existing accounts)
+ const user = await db
+ .selectFrom('users')
+ .select(['id', 'email', 'name', 'disabled'])
+ .where('email', '=', email.toLowerCase().trim())
+ .executeTakeFirst();
+
+ if (!user) {
+ return {
+ success: false,
+ error: 'No account found for this email',
+ errorCode: AuthErrorCode.INVALID_CREDENTIALS,
+ };
+ }
+
+ if (user.disabled) {
+ return {
+ success: false,
+ error: 'This account has been disabled',
+ errorCode: AuthErrorCode.USER_DISABLED,
+ };
+ }
+
+ return {
+ success: true,
+ providerUserId: user.email, // stable external ID for this provider
+ email: user.email,
+ emailVerified: true, // magic-link implies ownership of the inbox
+ name: user.name,
+ };
+ }
+
+ supportsRedirect(): boolean {
+ return false; // credential-based, not redirect-based
+ }
+
+ validateConfig(): boolean {
+ const ttl = this.providerConfig.tokenTtlSeconds;
+ if (ttl !== undefined && (typeof ttl !== 'number' || ttl < 60)) {
+ return false;
+ }
+ return true;
+ }
+
+ async testConnection(): Promise<{ success: boolean; message: string }> {
+ return { success: true, message: 'Magic-link provider is always available' };
+ }
+}
+```
+
+### 2. Register the type in the factory
+
+```typescript
+// src/modules/auth/providers/registry.ts — add one case
+
+function createProvider(config: AuthProviderConfig): AuthProvider {
+ switch (config.type) {
+ case 'local': return new LocalProvider(config);
+ case 'oidc': return new OidcProvider(config);
+ case 'ldap': return new LdapProvider(config);
+ case 'magic_link': return new MagicLinkProvider(config); // ← add this
+ default: throw new Error(`Unknown provider type: ${config.type}`);
+ }
+}
+```
+
+### 3. Seed a provider row (migration or admin API)
+
+```sql
+INSERT INTO auth_providers (type, name, slug, enabled, is_default, display_order, config)
+VALUES (
+ 'magic_link',
+ 'Magic Link',
+ 'magic-link',
+ true,
+ false,
+ 10,
+ '{"tokenTtlSeconds": 900}'::jsonb
+);
+```
+
+Or via the admin API:
+
+```bash
+curl -X POST /api/v1/admin/auth/providers \
+ -H 'Authorization: Bearer ' \
+ -H 'Content-Type: application/json' \
+ -d '{
+ "type": "magic_link",
+ "name": "Magic Link",
+ "slug": "magic-link",
+ "enabled": true,
+ "config": { "tokenTtlSeconds": 900 }
+ }'
+```
+
+### 4. Call the provider from a route
+
+The existing `POST /api/v1/auth/providers/:slug/login` route (for credential-based providers)
+will automatically dispatch to your provider once the row exists in the database. No new route is
+needed for credential-based flows.
+
+```bash
+# A separate endpoint would issue the token to the user's inbox (not shown here),
+# then the client presents it:
+curl -X POST /api/v1/auth/providers/magic-link/login \
+ -H 'Content-Type: application/json' \
+ -d '{"token": "a3f9..."}'
+```
+
+`AuthenticationService.authenticateWithProvider('magic-link', { token })` resolves the rest:
+it calls `MagicLinkProvider.authenticate()`, provisions the user if needed, and returns a session.
+
+---
+
+## Capability Gate
+
+SSO providers (any type other than `local`) are gated behind the `auth.sso` capability:
+
+```typescript
+// src/capabilities/registry.ts
+'auth.sso': {
+ kind: 'boolean',
+ defaultEnabled: true, // enabled for all plans in OSS
+ description: 'Single sign-on / external auth provider selection',
+}
+```
+
+The default is `true`, so all Logtide deployments can use external providers out of the box.
+Cloud or enterprise distributions can override this per-organization via
+`organization_entitlements`.
+
+---
+
+## What Is Not Abstracted
+
+The following are intentionally **outside** the provider interface boundary:
+
+- **Session storage** — sessions remain server-side opaque tokens in the `sessions` table,
+ independent of how the user authenticated.
+- **User management** — creating, disabling, and deleting users lives in `UsersService`, not in
+ providers. Providers only assert identity; they do not manage the user record.
+- **SAML** — not yet implemented. The `AuthInput` union can be extended with a
+ `{ type: 'saml_assertion'; samlResponse: string }` branch when a SAML provider is added as a
+ separate module, without modifying this interface or the session layer.
+- **Authorization** — what a user can do after login (roles, organization membership) is handled
+ by the capabilities and tenant isolation layers, not by auth providers.
diff --git a/package.json b/package.json
index b56f0384..29ab4ac1 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "logtide",
- "version": "1.1.0",
+ "version": "1.2.0",
"private": true,
"description": "LogTide - Self-hosted log management platform",
"author": "LogTide Team",
@@ -17,7 +17,7 @@
"qs": ">=6.15.2",
"devalue": ">=5.8.1",
"picomatch": ">=4.0.4",
- "brace-expansion": ">=5.0.6",
+ "brace-expansion": ">=5.0.7",
"fast-xml-parser": ">=5.7.0",
"fast-uri": ">=3.1.2",
"minimatch": ">=10.2.3",
@@ -25,7 +25,7 @@
"rollup": ">=4.59.0",
"svelte": ">=5.55.7",
"@sveltejs/kit": ">=2.60.1",
- "protobufjs": ">=7.6.3 <8",
+ "protobufjs": ">=7.6.5 <8",
"@protobufjs/utf8": ">=1.1.1",
"kysely": ">=0.28.17 <0.29",
"ws": ">=8.20.1",
diff --git a/packages/backend/migrations/053_digest_last_sent.sql b/packages/backend/migrations/053_digest_last_sent.sql
new file mode 100644
index 00000000..89c81cb2
--- /dev/null
+++ b/packages/backend/migrations/053_digest_last_sent.sql
@@ -0,0 +1,7 @@
+-- ============================================================================
+-- Migration 053: Digest dispatch bookkeeping
+-- Adds last_sent_at to digest_configs. The hourly digest-dispatch cron uses it
+-- as a double-fire guard and operators can see when a digest last went out.
+-- ============================================================================
+
+ALTER TABLE digest_configs ADD COLUMN IF NOT EXISTS last_sent_at TIMESTAMPTZ;
diff --git a/packages/backend/migrations/054_exception_service_attribution.sql b/packages/backend/migrations/054_exception_service_attribution.sql
new file mode 100644
index 00000000..d0467439
--- /dev/null
+++ b/packages/backend/migrations/054_exception_service_attribution.sql
@@ -0,0 +1,71 @@
+-- ============================================================================
+-- Migration 054: Engine-independent service attribution for error groups
+-- The error-group trigger resolved a group's affected service with
+-- `SELECT service FROM logs WHERE id = NEW.log_id`, but on ClickHouse and
+-- MongoDB reservoir backends the ingested logs never land in the Postgres
+-- `logs` table, so the lookup always returned NULL and every error group was
+-- attributed to 'unknown'. The ingestion path already knows the service, so we
+-- carry it on the exception row and have the trigger prefer it, falling back to
+-- the logs lookup (TimescaleDB) and only then to 'unknown'.
+-- ============================================================================
+
+ALTER TABLE exceptions ADD COLUMN IF NOT EXISTS service TEXT;
+
+CREATE OR REPLACE FUNCTION update_error_group_on_exception()
+RETURNS TRIGGER AS $$
+DECLARE
+ v_service TEXT;
+BEGIN
+ -- Prefer the service carried on the exception (known at ingestion, works on
+ -- every storage engine); fall back to the Postgres logs table (TimescaleDB)
+ -- and finally to 'unknown'.
+ v_service := NEW.service;
+
+ IF v_service IS NULL THEN
+ SELECT service INTO v_service
+ FROM logs
+ WHERE id = NEW.log_id
+ LIMIT 1;
+ END IF;
+
+ v_service := COALESCE(v_service, 'unknown');
+
+ -- Insert or update error group
+ INSERT INTO error_groups (
+ organization_id,
+ project_id,
+ fingerprint,
+ exception_type,
+ exception_message,
+ language,
+ occurrence_count,
+ first_seen,
+ last_seen,
+ affected_services,
+ sample_log_id
+ )
+ VALUES (
+ NEW.organization_id,
+ NEW.project_id,
+ NEW.fingerprint,
+ NEW.exception_type,
+ NEW.exception_message,
+ NEW.language,
+ 1,
+ NEW.created_at,
+ NEW.created_at,
+ ARRAY[v_service],
+ NEW.log_id
+ )
+ ON CONFLICT (organization_id, COALESCE(project_id, '00000000-0000-0000-0000-000000000000'::UUID), fingerprint)
+ DO UPDATE SET
+ occurrence_count = error_groups.occurrence_count + 1,
+ last_seen = NEW.created_at,
+ affected_services = (
+ SELECT ARRAY(SELECT DISTINCT unnest(array_cat(error_groups.affected_services, ARRAY[v_service])))
+ ),
+ updated_at = NOW();
+
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
diff --git a/packages/backend/migrations/055_error_group_auto_merge.sql b/packages/backend/migrations/055_error_group_auto_merge.sql
new file mode 100644
index 00000000..43e18c30
--- /dev/null
+++ b/packages/backend/migrations/055_error_group_auto_merge.sql
@@ -0,0 +1,127 @@
+-- ============================================================================
+-- Migration 055: Auto-merge error groups at ingestion
+-- Stack fingerprints split one logical error into several groups when the deep
+-- stack varies (source maps, async internal frames, different callers). We add a
+-- coarser "merge key" (exception type + normalized message + the top application
+-- frame) so a new occurrence folds into an existing group instead of creating a
+-- duplicate. The key is computed by ONE Postgres function used by the backfill,
+-- the trigger, and the runtime fold lookup, so there is no SQL/JS normalization
+-- to keep in sync. See docs/superpowers/specs/2026-07-21-error-group-auto-merge-design.md
+-- ============================================================================
+
+-- Single source of truth for the merge key. IMMUTABLE so it can be used in a
+-- WHERE clause against the merge_key index. Returns NULL when there is no app
+-- frame, so library-only errors are never auto-merged. md5 (not a security
+-- digest, just an internal grouping key) avoids a pgcrypto dependency.
+CREATE OR REPLACE FUNCTION logtide_merge_key(p_type TEXT, p_message TEXT, p_top_frame TEXT)
+RETURNS TEXT AS $$
+DECLARE
+ m TEXT;
+BEGIN
+ IF p_top_frame IS NULL THEN
+ RETURN NULL;
+ END IF;
+
+ m := COALESCE(p_message, '');
+ -- UUIDs first (they contain hex runs the next step would otherwise match).
+ m := regexp_replace(m, '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}', '', 'gi');
+ -- Long hex runs (request ids, hashes, addresses).
+ m := regexp_replace(m, '\y[0-9a-f]{8,}\y', '', 'gi');
+ m := regexp_replace(m, '[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}', '', 'gi');
+ m := regexp_replace(m, '''[^'']*''|"[^"]*"', '', 'g');
+ m := regexp_replace(m, '\d+(\.\d+)?', '', 'g');
+ m := btrim(regexp_replace(m, '\s+', ' ', 'g'));
+
+ RETURN md5(p_type || E'\n' || m || E'\n' || p_top_frame);
+END;
+$$ LANGUAGE plpgsql IMMUTABLE;
+
+ALTER TABLE error_groups ADD COLUMN IF NOT EXISTS merge_key TEXT;
+CREATE INDEX IF NOT EXISTS idx_error_groups_merge_key ON error_groups (organization_id, merge_key);
+
+-- Raw "file:function" of the first app frame, carried from the app (which has the
+-- parsed frames) so the trigger can compute the group's merge key.
+ALTER TABLE exceptions ADD COLUMN IF NOT EXISTS top_frame TEXT;
+
+CREATE OR REPLACE FUNCTION update_error_group_on_exception()
+RETURNS TRIGGER AS $$
+DECLARE
+ v_service TEXT;
+ v_merge_key TEXT;
+BEGIN
+ -- Service: prefer the value carried on the exception (works on every storage
+ -- engine), fall back to the Postgres logs table (TimescaleDB), then 'unknown'.
+ v_service := NEW.service;
+ IF v_service IS NULL THEN
+ SELECT service INTO v_service
+ FROM logs
+ WHERE id = NEW.log_id
+ LIMIT 1;
+ END IF;
+ v_service := COALESCE(v_service, 'unknown');
+
+ v_merge_key := logtide_merge_key(NEW.exception_type, NEW.exception_message, NEW.top_frame);
+
+ INSERT INTO error_groups (
+ organization_id,
+ project_id,
+ fingerprint,
+ exception_type,
+ exception_message,
+ language,
+ occurrence_count,
+ first_seen,
+ last_seen,
+ affected_services,
+ sample_log_id,
+ merge_key
+ )
+ VALUES (
+ NEW.organization_id,
+ NEW.project_id,
+ NEW.fingerprint,
+ NEW.exception_type,
+ NEW.exception_message,
+ NEW.language,
+ 1,
+ NEW.created_at,
+ NEW.created_at,
+ ARRAY[v_service],
+ NEW.log_id,
+ v_merge_key
+ )
+ ON CONFLICT (organization_id, COALESCE(project_id, '00000000-0000-0000-0000-000000000000'::UUID), fingerprint)
+ DO UPDATE SET
+ occurrence_count = error_groups.occurrence_count + 1,
+ last_seen = NEW.created_at,
+ affected_services = (
+ SELECT ARRAY(SELECT DISTINCT unnest(array_cat(error_groups.affected_services, ARRAY[v_service])))
+ ),
+ merge_key = COALESCE(error_groups.merge_key, v_merge_key),
+ updated_at = NOW();
+
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+-- Backfill existing groups so historical duplicates can fold forward.
+UPDATE error_groups g
+SET merge_key = logtide_merge_key(g.exception_type, g.exception_message, tf.top_frame)
+FROM (
+ SELECT
+ eg.id AS group_id,
+ (
+ -- Prefer the source-mapped frame, matching FingerprintService.topAppFrame()
+ -- so backfilled keys line up with keys the runtime computes for new
+ -- occurrences (which carry original_file/original_function when available).
+ SELECT COALESCE(sf.original_file, sf.file_path) || ':' ||
+ COALESCE(sf.original_function, sf.function_name, '')
+ FROM exceptions e
+ JOIN stack_frames sf ON sf.exception_id = e.id
+ WHERE e.log_id = eg.sample_log_id AND sf.is_app_code = TRUE
+ ORDER BY sf.frame_index ASC
+ LIMIT 1
+ ) AS top_frame
+ FROM error_groups eg
+) tf
+WHERE g.id = tf.group_id;
diff --git a/packages/backend/package.json b/packages/backend/package.json
index 5124cd30..d61d9942 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "@logtide/backend",
- "version": "1.1.0",
+ "version": "1.2.0",
"private": true,
"description": "LogTide Backend API",
"type": "module",
diff --git a/packages/backend/src/capabilities/registry.ts b/packages/backend/src/capabilities/registry.ts
index 1d5ca1ca..ff671146 100644
--- a/packages/backend/src/capabilities/registry.ts
+++ b/packages/backend/src/capabilities/registry.ts
@@ -99,6 +99,11 @@ export const CAPABILITIES = {
defaultLimit: null,
description: 'Maximum inbound webhook receivers per organization',
},
+ 'digests.max_recipients': {
+ kind: 'limit',
+ defaultLimit: null,
+ description: 'Maximum digest email recipients per organization',
+ },
// Consumption quotas (OSS-permissive: null = unlimited). signal maps to #212 metering types.
'ingestion.max_bytes_monthly': {
diff --git a/packages/backend/src/database/types.ts b/packages/backend/src/database/types.ts
index 72c5e03a..ba9cbc95 100644
--- a/packages/backend/src/database/types.ts
+++ b/packages/backend/src/database/types.ts
@@ -641,6 +641,8 @@ export interface ExceptionsTable {
fingerprint: string;
raw_stack_trace: string;
frame_count: number;
+ service: string | null;
+ top_frame: string | null;
created_at: Generated;
}
@@ -689,6 +691,7 @@ export interface ErrorGroupsTable {
resolved_by: string | null;
affected_services: string[] | null;
sample_log_id: string | null;
+ merge_key: string | null;
last_notified_at: Timestamp | null;
created_at: Generated;
updated_at: Generated;
@@ -1138,6 +1141,7 @@ export interface DigestConfigsTable {
delivery_hour: number;
delivery_day_of_week: number | null;
enabled: Generated;
+ last_sent_at: Timestamp | null;
created_at: Generated;
updated_at: Generated;
}
diff --git a/packages/backend/src/lib/email-templates.ts b/packages/backend/src/lib/email-templates.ts
index fce0953b..dcba239a 100644
--- a/packages/backend/src/lib/email-templates.ts
+++ b/packages/backend/src/lib/email-templates.ts
@@ -97,10 +97,13 @@ const colors = {
interface BaseEmailOptions {
preheader?: string; // Preview text shown in email clients
+ // When set, the footer swaps the channel-settings link for a one-click
+ // unsubscribe link (used by digest emails, where recipients may have no account)
+ unsubscribeUrl?: string;
}
function baseTemplate(content: string, options: BaseEmailOptions = {}): string {
- const { preheader } = options;
+ const { preheader, unsubscribeUrl } = options;
const frontendUrl = getFrontendUrl();
const logoUrl = getLogoUrl();
@@ -143,7 +146,9 @@ function baseTemplate(content: string, options: BaseEmailOptions = {}): string {
Sent by LogTide
- Manage notification settings
+ ${unsubscribeUrl
+ ? `Unsubscribe from these reports `
+ : `Manage notification settings `}
@@ -833,3 +838,254 @@ Manage notifications: ${frontendUrl}/dashboard/settings/channels
return { html, text };
}
+
+// ============================================================================
+// DIGEST REPORT EMAIL (#154)
+// ============================================================================
+
+export interface DigestEmailData {
+ organizationName: string;
+ frequency: 'daily' | 'weekly';
+ periodLabel: string;
+ logVolume: {
+ current: number;
+ previous: number;
+ trend: string;
+ };
+ topErrorServices: Array<{
+ service: string;
+ errorCount: number;
+ previousCount: number;
+ delta: number;
+ }>;
+ newErrorGroups: Array<{
+ exceptionType: string;
+ exceptionMessage: string;
+ occurrenceCount: number;
+ language: string;
+ }>;
+ security: {
+ totalDetections: number;
+ topRules: Array<{ ruleTitle: string; severity: string; count: number }>;
+ openIncidents: number;
+ };
+ uptime: {
+ monitorCount: number;
+ overallUptimePct: number;
+ worstMonitors: Array<{ name: string; uptimePct: number }>;
+ } | null;
+ unsubscribeUrl: string;
+ dashboardUrl: string;
+}
+
+function digestSectionTitle(title: string): string {
+ return `
+
+
+ ${escapeHtml(title)}
+
+
+ `;
+}
+
+function digestEmptyLine(message: string): string {
+ return `
+
+ ${escapeHtml(message)}
+
+ `;
+}
+
+function digestTable(headers: string[], rows: string[][]): string {
+ const headerCells = headers
+ .map(
+ (h, i) =>
+ `${escapeHtml(h)} `
+ )
+ .join('');
+ const bodyRows = rows
+ .map(
+ (cells) =>
+ `${cells
+ .map(
+ (c, i) =>
+ `${escapeHtml(c)} `
+ )
+ .join('')} `
+ )
+ .join('');
+
+ return `
+
+
+ ${headerCells}
+ ${bodyRows}
+
+
+ `;
+}
+
+function formatDelta(delta: number): string {
+ if (delta > 0) return `+${delta.toLocaleString('en-US')}`;
+ return delta.toLocaleString('en-US');
+}
+
+export function generateDigestEmail(data: DigestEmailData): { html: string; text: string } {
+ const frequencyLabel = data.frequency === 'daily' ? 'Daily' : 'Weekly';
+ const title = `${frequencyLabel} Digest`;
+ const quiet = data.logVolume.current === 0 && data.logVolume.previous === 0;
+
+ const logVolumeSection = quiet
+ ? alertBox('No activity during this period. Your systems have been quiet.', 'info')
+ : infoBox([
+ { label: 'Total Logs', value: data.logVolume.current.toLocaleString('en-US') },
+ { label: 'Trend', value: data.logVolume.trend },
+ { label: 'Previous Period', value: data.logVolume.previous.toLocaleString('en-US') },
+ ]);
+
+ const topServicesSection =
+ data.topErrorServices.length > 0
+ ? digestTable(
+ ['Service', 'Errors', 'Previous', 'Delta'],
+ data.topErrorServices.map((s) => [
+ s.service,
+ s.errorCount.toLocaleString('en-US'),
+ s.previousCount.toLocaleString('en-US'),
+ formatDelta(s.delta),
+ ])
+ )
+ : digestEmptyLine('No errors recorded in this period.');
+
+ const errorGroupsSection =
+ data.newErrorGroups.length > 0
+ ? digestTable(
+ ['Error', 'Language', 'Occurrences'],
+ data.newErrorGroups.map((g) => [
+ `${g.exceptionType}${g.exceptionMessage ? ': ' + truncate(g.exceptionMessage, 60) : ''}`,
+ g.language,
+ g.occurrenceCount.toLocaleString('en-US'),
+ ])
+ )
+ : digestEmptyLine('No new error groups appeared in this period.');
+
+ const securityRows = [
+ { label: 'Detections', value: data.security.totalDetections.toLocaleString('en-US') },
+ { label: 'Open Incidents', value: data.security.openIncidents.toLocaleString('en-US') },
+ ];
+ const securitySection =
+ data.security.topRules.length > 0
+ ? infoBox(securityRows) +
+ digestTable(
+ ['Rule', 'Severity', 'Detections'],
+ data.security.topRules.map((r) => [r.ruleTitle, r.severity, r.count.toLocaleString('en-US')])
+ )
+ : infoBox(securityRows);
+
+ const uptimeSection = data.uptime
+ ? digestSectionTitle('Uptime') +
+ infoBox([
+ { label: 'Overall', value: `${data.uptime.overallUptimePct.toLocaleString('en-US')}%` },
+ { label: 'Monitors', value: data.uptime.monitorCount.toLocaleString('en-US') },
+ ]) +
+ (data.uptime.worstMonitors.length > 0
+ ? digestTable(
+ ['Monitor', 'Uptime'],
+ data.uptime.worstMonitors.map((m) => [m.name, `${m.uptimePct.toLocaleString('en-US')}%`])
+ )
+ : '')
+ : '';
+
+ const html = baseTemplate(
+ card(`
+ ${header(title, { text: data.frequency, color: colors.info })}
+ ${divider()}
+ ${subtitle(`${data.organizationName} - ${data.periodLabel}`)}
+ ${timestamp()}
+ ${digestSectionTitle('Log Volume')}
+ ${logVolumeSection}
+ ${digestSectionTitle('Top Services by Errors')}
+ ${topServicesSection}
+ ${digestSectionTitle('New Error Groups')}
+ ${errorGroupsSection}
+ ${digestSectionTitle('Security')}
+ ${securitySection}
+ ${uptimeSection}
+ ${cta('View Dashboard', data.dashboardUrl)}
+ `),
+ {
+ preheader: quiet
+ ? `Quiet period for ${data.organizationName}`
+ : `${data.logVolume.current.toLocaleString('en-US')} logs, ${data.security.totalDetections.toLocaleString('en-US')} detections for ${data.organizationName}`,
+ unsubscribeUrl: data.unsubscribeUrl,
+ }
+ );
+
+ const lines: string[] = [];
+ lines.push(`LogTide ${frequencyLabel} Digest`);
+ lines.push(`Organization: ${data.organizationName}`);
+ lines.push(`Period: ${data.periodLabel}`);
+ lines.push('');
+ lines.push('='.repeat(50));
+ lines.push('');
+ lines.push('LOG VOLUME');
+ lines.push('-'.repeat(10));
+ if (quiet) {
+ lines.push('No activity during this period.');
+ lines.push('Your systems have been quiet.');
+ } else {
+ lines.push(`Total logs: ${data.logVolume.current.toLocaleString('en-US')}`);
+ lines.push(`Trend: ${data.logVolume.trend}`);
+ lines.push(`Previous period: ${data.logVolume.previous.toLocaleString('en-US')}`);
+ }
+ lines.push('');
+ lines.push('TOP SERVICES BY ERRORS');
+ lines.push('-'.repeat(22));
+ if (data.topErrorServices.length > 0) {
+ for (const s of data.topErrorServices) {
+ lines.push(
+ `${s.service}: ${s.errorCount.toLocaleString('en-US')} errors (previous: ${s.previousCount.toLocaleString('en-US')}, delta: ${formatDelta(s.delta)})`
+ );
+ }
+ } else {
+ lines.push('No errors recorded in this period.');
+ }
+ lines.push('');
+ lines.push('NEW ERROR GROUPS');
+ lines.push('-'.repeat(16));
+ if (data.newErrorGroups.length > 0) {
+ for (const g of data.newErrorGroups) {
+ const message = g.exceptionMessage ? `: ${truncate(g.exceptionMessage, 80)}` : '';
+ lines.push(`${g.exceptionType}${message} (${g.language}, ${g.occurrenceCount.toLocaleString('en-US')} occurrences)`);
+ }
+ } else {
+ lines.push('No new error groups appeared in this period.');
+ }
+ lines.push('');
+ lines.push('SECURITY');
+ lines.push('-'.repeat(8));
+ lines.push(`Detections: ${data.security.totalDetections.toLocaleString('en-US')}`);
+ lines.push(`Open incidents: ${data.security.openIncidents.toLocaleString('en-US')}`);
+ for (const r of data.security.topRules) {
+ lines.push(`${r.ruleTitle} [${r.severity}]: ${r.count.toLocaleString('en-US')} detections`);
+ }
+ if (data.uptime) {
+ lines.push('');
+ lines.push('UPTIME');
+ lines.push('-'.repeat(6));
+ lines.push(`Overall: ${data.uptime.overallUptimePct.toLocaleString('en-US')}% across ${data.uptime.monitorCount.toLocaleString('en-US')} monitor(s)`);
+ for (const m of data.uptime.worstMonitors) {
+ lines.push(`${m.name}: ${m.uptimePct.toLocaleString('en-US')}%`);
+ }
+ }
+ lines.push('');
+ lines.push(`View your dashboard: ${data.dashboardUrl}`);
+ lines.push('');
+ lines.push('To unsubscribe from these reports, click:');
+ lines.push(data.unsubscribeUrl);
+ lines.push('');
+ lines.push('--');
+ lines.push('Sent by LogTide');
+
+ return { html, text: lines.join('\n') };
+}
+
diff --git a/packages/backend/src/modules/admin/service.ts b/packages/backend/src/modules/admin/service.ts
index d1a77899..356e0249 100644
--- a/packages/backend/src/modules/admin/service.ts
+++ b/packages/backend/src/modules/admin/service.ts
@@ -1234,6 +1234,9 @@ export class AdminService {
'ingestion.detection_enqueue_failed',
'ingestion.exception_enqueue_failed',
'ingestion.identifier_failed',
+ 'ingestion.timestamp_skew',
+ 'ingestion.span_timestamp_skew',
+ 'ingestion.metric_timestamp_skew',
])
.groupBy('type')
.execute();
@@ -1249,6 +1252,9 @@ export class AdminService {
detectionEnqueueFailed: byType['ingestion.detection_enqueue_failed'] ?? 0,
exceptionEnqueueFailed: byType['ingestion.exception_enqueue_failed'] ?? 0,
identifierFailed: byType['ingestion.identifier_failed'] ?? 0,
+ timestampSkew: byType['ingestion.timestamp_skew'] ?? 0,
+ spanTimestampSkew: byType['ingestion.span_timestamp_skew'] ?? 0,
+ metricTimestampSkew: byType['ingestion.metric_timestamp_skew'] ?? 0,
},
enrichment: enrichmentService.getStatus(),
};
diff --git a/packages/backend/src/modules/audit-log/actions.ts b/packages/backend/src/modules/audit-log/actions.ts
index c6dca7fc..72e88839 100644
--- a/packages/backend/src/modules/audit-log/actions.ts
+++ b/packages/backend/src/modules/audit-log/actions.ts
@@ -65,6 +65,12 @@ export const AUDIT_ACTIONS = {
'channel.created': 'config_change',
'channel.updated': 'config_change',
'channel.deleted': 'config_change',
+ // email digest reports (#154)
+ 'digest.config_updated': 'config_change',
+ 'digest.config_deleted': 'config_change',
+ 'digest.recipient_added': 'config_change',
+ 'digest.recipient_removed': 'config_change',
+ 'digest.recipient_resubscribed': 'config_change',
// webhooks
'webhook.delivery_replayed': 'config_change',
// auth
diff --git a/packages/backend/src/modules/auth/authentication-service.ts b/packages/backend/src/modules/auth/authentication-service.ts
index be265513..53c550be 100644
--- a/packages/backend/src/modules/auth/authentication-service.ts
+++ b/packages/backend/src/modules/auth/authentication-service.ts
@@ -16,6 +16,8 @@ import type { UserProfile, SessionInfo } from '../users/service.js';
import { settingsService } from '../settings/service.js';
import {
providerRegistry,
+ AuthError,
+ AuthErrorCode,
type AuthProvider,
type AuthenticationResult,
type UserIdentity,
@@ -51,14 +53,20 @@ export class AuthenticationService {
// Get provider
const provider = await providerRegistry.getProvider(providerSlug);
if (!provider) {
- throw new Error(`Authentication provider '${providerSlug}' not found or disabled`);
+ throw new AuthError(
+ `Authentication provider '${providerSlug}' not found or disabled`,
+ AuthErrorCode.PROVIDER_UNAVAILABLE
+ );
}
// Authenticate with provider
const result = await provider.authenticate(credentials);
if (!result.success) {
- throw new Error(result.error || 'Authentication failed');
+ throw new AuthError(
+ result.error || 'Authentication failed',
+ result.errorCode ?? AuthErrorCode.PROVIDER_ERROR
+ );
}
// Find or create user
@@ -171,7 +179,7 @@ export class AuthenticationService {
// Get provider
const provider = await providerRegistry.getProviderById(stateData.providerId);
if (!provider) {
- throw new Error('Authentication provider not found or disabled');
+ throw new AuthError('Authentication provider not found or disabled', AuthErrorCode.PROVIDER_UNAVAILABLE);
}
if (!provider.handleCallback) {
@@ -210,7 +218,10 @@ export class AuthenticationService {
await CacheManager.delete(`oidc:state:${state}`);
if (!result.success) {
- throw new Error(result.error || 'Authentication failed');
+ throw new AuthError(
+ result.error || 'Authentication failed',
+ result.errorCode ?? AuthErrorCode.PROVIDER_ERROR
+ );
}
// Find or create user
@@ -257,7 +268,7 @@ export class AuthenticationService {
if (existingIdentity) {
// Check if user is disabled
if (existingIdentity.disabled) {
- throw new Error('This account has been disabled');
+ throw new AuthError('This account has been disabled', AuthErrorCode.USER_DISABLED);
}
// Update user's last login
@@ -291,7 +302,7 @@ export class AuthenticationService {
if (existingUser) {
// Check if user is disabled
if (existingUser.disabled) {
- throw new Error('This account has been disabled');
+ throw new AuthError('This account has been disabled', AuthErrorCode.USER_DISABLED);
}
// Only auto-link to a pre-existing account when the provider asserts the
diff --git a/packages/backend/src/modules/auth/providers/index.ts b/packages/backend/src/modules/auth/providers/index.ts
index 5d6eebaa..4e843d7f 100644
--- a/packages/backend/src/modules/auth/providers/index.ts
+++ b/packages/backend/src/modules/auth/providers/index.ts
@@ -19,7 +19,7 @@ export type {
UserIdentity,
} from './types.js';
-export { type AuthProviderType, AuthErrorCode } from './types.js';
+export { type AuthProviderType, AuthErrorCode, AuthError } from './types.js';
// Providers
export { LocalProvider } from './local-provider.js';
diff --git a/packages/backend/src/modules/auth/providers/local-provider.ts b/packages/backend/src/modules/auth/providers/local-provider.ts
index 09d99289..dd362006 100644
--- a/packages/backend/src/modules/auth/providers/local-provider.ts
+++ b/packages/backend/src/modules/auth/providers/local-provider.ts
@@ -57,26 +57,28 @@ export class LocalProvider implements AuthProvider {
return {
success: false,
error: 'Please log in using your organization SSO',
- errorCode: AuthErrorCode.INVALID_CREDENTIALS,
+ errorCode: AuthErrorCode.SSO_REQUIRED,
};
}
- // Check if user is disabled
- if (user.disabled) {
+ // Verify password before any other checks to prevent user enumeration:
+ // a wrong password must always return the same generic error regardless of
+ // whether the account is disabled or not.
+ const isValid = await bcrypt.compare(password, user.password_hash);
+ if (!isValid) {
return {
success: false,
- error: 'This account has been disabled',
- errorCode: AuthErrorCode.USER_DISABLED,
+ error: 'Invalid email or password',
+ errorCode: AuthErrorCode.INVALID_CREDENTIALS,
};
}
- // Verify password
- const isValid = await bcrypt.compare(password, user.password_hash);
- if (!isValid) {
+ // Check if user is disabled (only after the password is confirmed correct)
+ if (user.disabled) {
return {
success: false,
- error: 'Invalid email or password',
- errorCode: AuthErrorCode.INVALID_CREDENTIALS,
+ error: 'This account has been disabled',
+ errorCode: AuthErrorCode.USER_DISABLED,
};
}
@@ -84,6 +86,7 @@ export class LocalProvider implements AuthProvider {
success: true,
providerUserId: user.email, // For local, we use email as the provider user ID
email: user.email,
+ emailVerified: true, // Password verification implies ownership of the account
name: user.name,
metadata: {
userId: user.id,
diff --git a/packages/backend/src/modules/auth/providers/types.ts b/packages/backend/src/modules/auth/providers/types.ts
index 7466d751..6cb8c258 100644
--- a/packages/backend/src/modules/auth/providers/types.ts
+++ b/packages/backend/src/modules/auth/providers/types.ts
@@ -79,6 +79,7 @@ export interface AuthenticationResult {
*/
export enum AuthErrorCode {
INVALID_CREDENTIALS = 'INVALID_CREDENTIALS',
+ SSO_REQUIRED = 'SSO_REQUIRED',
USER_DISABLED = 'USER_DISABLED',
PROVIDER_UNAVAILABLE = 'PROVIDER_UNAVAILABLE',
PROVIDER_ERROR = 'PROVIDER_ERROR',
@@ -89,6 +90,23 @@ export enum AuthErrorCode {
ACCOUNT_LOCKED = 'ACCOUNT_LOCKED',
}
+/**
+ * Typed authentication error.
+ *
+ * Carries the provider's AuthErrorCode end to end so callers (HTTP routes,
+ * audit logging) can classify a failure by code instead of matching the
+ * user-facing message string, which would silently break on any reword.
+ */
+export class AuthError extends Error {
+ readonly code: AuthErrorCode;
+
+ constructor(message: string, code: AuthErrorCode) {
+ super(message);
+ this.name = 'AuthError';
+ this.code = code;
+ }
+}
+
/**
* Credentials for local (email/password) authentication
*/
diff --git a/packages/backend/src/modules/dashboard/service.ts b/packages/backend/src/modules/dashboard/service.ts
index 938a28e2..9f64d67f 100644
--- a/packages/backend/src/modules/dashboard/service.ts
+++ b/packages/backend/src/modules/dashboard/service.ts
@@ -49,6 +49,20 @@ export interface TimelineEvent {
detectionsBySeverity: { critical: number; high: number; medium: number; low: number };
}
+/**
+ * Below this recent (last-hour) log volume, dashboard "today" stats are computed
+ * from raw logs instead of the hourly continuous aggregate. The aggregate only
+ * reflects data below its materialization watermark that its refresh policy has
+ * actually processed (start_offset = 3h); on instances where the policy is not
+ * keeping the aggregate warm, "today" collapses to roughly the last hour (the
+ * only part read from raw), which is what makes "Total Logs Today" and the error
+ * rate read far too low. Verified live on the hosted demo: 24h = ~1,500 logs,
+ * last hour = ~67, yet "Total Logs Today" showed ~60. A raw count over the
+ * bounded today/yesterday window is exact and cheap at low volume; higher-volume
+ * instances keep the fast aggregate path (their live ingestion keeps it warm).
+ */
+const RAW_STATS_MAX_RECENT_VOLUME = 5000;
+
class DashboardService {
/**
* Resolve project IDs from org or single project.
@@ -163,6 +177,13 @@ class DashboardService {
});
const recentVolume = recentTotalResult.count;
+ // Low-volume instances (fresh installs, backfilled/seeded data, or a stale
+ // continuous aggregate) are exactly where the aggregate undercounts "today",
+ // so count today/yesterday from raw for an exact, cheap result.
+ if (recentVolume <= RAW_STATS_MAX_RECENT_VOLUME) {
+ return this.getStatsFromRawLogs(projectIds, todayStart, yesterdayStart, lastHourStart, prevHourStart);
+ }
+
// 2. Query aggregate for historical data and reservoir for recent data in parallel
const [todayAggregateStats, recentTotal, recentErrors, recentServices, yesterdayAggregateStats, prevHourCount] = await Promise.all([
// Today's historical stats from aggregate (today start to 1 hour ago)
diff --git a/packages/backend/src/modules/digests/generator.ts b/packages/backend/src/modules/digests/generator.ts
index 518a2781..67b35447 100644
--- a/packages/backend/src/modules/digests/generator.ts
+++ b/packages/backend/src/modules/digests/generator.ts
@@ -1,7 +1,9 @@
/**
* Digest Generator Service
*
- * Generates and sends email digest reports summarizing log activity over a period.
+ * Generates and sends email digest reports summarizing organization activity
+ * over a period: log volume, top services by errors, new error groups,
+ * security detections and monitor uptime.
*/
import nodemailer from 'nodemailer';
@@ -9,12 +11,40 @@ import { db } from '../../database/connection.js';
import { reservoir } from '../../database/reservoir.js';
import { hub } from '@logtide/core';
import { config } from '../../config/index.js';
+import { generateDigestEmail } from '../../lib/email-templates.js';
import type { DigestJobPayload } from './scheduler.js';
-interface LogVolumeStats {
- currentPeriodCount: number;
- previousPeriodCount: number;
- trend: string;
+export interface DigestReportData {
+ organizationName: string;
+ frequency: 'daily' | 'weekly';
+ periodLabel: string;
+ logVolume: {
+ current: number;
+ previous: number;
+ trend: string;
+ };
+ topErrorServices: Array<{
+ service: string;
+ errorCount: number;
+ previousCount: number;
+ delta: number;
+ }>;
+ newErrorGroups: Array<{
+ exceptionType: string;
+ exceptionMessage: string;
+ occurrenceCount: number;
+ language: string;
+ }>;
+ security: {
+ totalDetections: number;
+ topRules: Array<{ ruleTitle: string; severity: string; count: number }>;
+ openIncidents: number;
+ };
+ uptime: {
+ monitorCount: number;
+ overallUptimePct: number;
+ worstMonitors: Array<{ name: string; uptimePct: number }>;
+ } | null;
}
interface DigestRecipient {
@@ -22,6 +52,14 @@ interface DigestRecipient {
unsubscribe_token: string;
}
+interface Period {
+ from: Date;
+ to: Date;
+ previousFrom: Date;
+ previousTo: Date;
+}
+
+const ERROR_LEVELS = ['error', 'critical'] as const;
let emailTransporter: nodemailer.Transporter | null = null;
@@ -53,14 +91,13 @@ function getEmailTransporter(): nodemailer.Transporter | null {
}
export class DigestGeneratorService {
-
+
async generateAndSendDigest(payload: DigestJobPayload): Promise {
const { organizationId, digestConfigId, frequency } = payload;
hub.captureLog('info', `[DigestGenerator] Generating ${frequency} digest for org ${organizationId}`);
try {
-
const organization = await db
.selectFrom('organizations')
.select(['name'])
@@ -71,7 +108,6 @@ export class DigestGeneratorService {
throw new Error(`Organization ${organizationId} not found`);
}
-
const recipients = await this.fetchRecipients(organizationId, digestConfigId);
if (recipients.length === 0) {
@@ -79,16 +115,9 @@ export class DigestGeneratorService {
return;
}
-
- const stats = await this.calculateLogVolume(organizationId, frequency);
+ const report = await this.buildReportData(organizationId, organization.name, frequency);
-
- await this.sendDigestEmails(
- recipients,
- organization.name,
- frequency,
- stats
- );
+ await this.sendDigestEmails(recipients, report);
hub.captureLog('info', `[DigestGenerator] Digest sent to ${recipients.length} recipient(s) for org ${organizationId}`);
} catch (error: any) {
@@ -97,92 +126,284 @@ export class DigestGeneratorService {
}
}
-
- private async fetchRecipients(
+ /**
+ * Compute all report sections for the period. Sections run sequentially:
+ * this is a background cron path where simplicity beats latency.
+ */
+ async buildReportData(
organizationId: string,
- digestConfigId: string
- ): Promise {
- const recipients = await db
- .selectFrom('digest_recipients')
- .select(['email', 'unsubscribe_token'])
+ organizationName: string,
+ frequency: 'daily' | 'weekly'
+ ): Promise {
+ const period = this.buildPeriod(frequency);
+
+ const projects = await db
+ .selectFrom('projects')
+ .select(['id'])
.where('organization_id', '=', organizationId)
- .where('digest_config_id', '=', digestConfigId)
- .where('subscribed', '=', true)
.execute();
+ const projectIds = projects.map((p) => p.id);
- return recipients;
+ const logVolume = await this.calculateLogVolume(projectIds, period);
+ const topErrorServices = await this.calculateTopErrorServices(projectIds, period);
+ const newErrorGroups = await this.calculateNewErrorGroups(organizationId, period);
+ const security = await this.calculateSecuritySummary(organizationId, period);
+ const uptime = await this.calculateUptimeSummary(organizationId, period);
+
+ return {
+ organizationName,
+ frequency,
+ periodLabel: frequency === 'daily' ? 'last 24 hours' : 'last 7 days',
+ logVolume,
+ topErrorServices,
+ newErrorGroups,
+ security,
+ uptime,
+ };
+ }
+
+ private buildPeriod(frequency: 'daily' | 'weekly'): Period {
+ // Sliding window relative to execution time
+ const hoursInPeriod = frequency === 'daily' ? 24 : 168;
+ const now = new Date();
+ const from = new Date(now.getTime() - hoursInPeriod * 60 * 60 * 1000);
+ const previousFrom = new Date(now.getTime() - hoursInPeriod * 2 * 60 * 60 * 1000);
+
+ return { from, to: now, previousFrom, previousTo: from };
}
private async calculateLogVolume(
+ projectIds: string[],
+ period: Period
+ ): Promise {
+ if (projectIds.length === 0) {
+ return { current: 0, previous: 0, trend: this.calculateTrend(0, 0) };
+ }
+
+ const currentResult = await reservoir.count({
+ projectId: projectIds,
+ from: period.from,
+ to: period.to,
+ toExclusive: true,
+ });
+
+ const previousResult = await reservoir.count({
+ projectId: projectIds,
+ from: period.previousFrom,
+ to: period.previousTo,
+ toExclusive: true,
+ });
+
+ return {
+ current: currentResult.count,
+ previous: previousResult.count,
+ trend: this.calculateTrend(currentResult.count, previousResult.count),
+ };
+ }
+
+ /**
+ * Top 5 services by error+critical log count, with the delta against the
+ * previous period. Engine-agnostic through reservoir.topValues.
+ */
+ private async calculateTopErrorServices(
+ projectIds: string[],
+ period: Period
+ ): Promise {
+ if (projectIds.length === 0) {
+ return [];
+ }
+
+ const current = await reservoir.topValues({
+ field: 'service',
+ projectId: projectIds,
+ level: [...ERROR_LEVELS],
+ from: period.from,
+ to: period.to,
+ limit: 5,
+ });
+
+ if (current.values.length === 0) {
+ return [];
+ }
+
+ // Wide limit so services that dropped out of the top 5 still resolve
+ const previous = await reservoir.topValues({
+ field: 'service',
+ projectId: projectIds,
+ level: [...ERROR_LEVELS],
+ from: period.previousFrom,
+ to: period.previousTo,
+ limit: 100,
+ });
+
+ const previousByService = new Map(previous.values.map((v) => [v.value, v.count]));
+
+ return current.values.map((v) => {
+ const previousCount = previousByService.get(v.value) ?? 0;
+ return {
+ service: v.value,
+ errorCount: v.count,
+ previousCount,
+ delta: v.count - previousCount,
+ };
+ });
+ }
+
+ /**
+ * Error groups whose first occurrence falls inside the period.
+ */
+ private async calculateNewErrorGroups(
organizationId: string,
- frequency: 'daily' | 'weekly'
- ): Promise {
- // Uses a 24-hour sliding window relative to execution time
- const hoursInPeriod = frequency === 'daily' ? 24 : 168; // 7 days = 168 hours
+ period: Period
+ ): Promise {
+ const groups = await db
+ .selectFrom('error_groups')
+ .select(['exception_type', 'exception_message', 'occurrence_count', 'language'])
+ .where('organization_id', '=', organizationId)
+ .where('first_seen', '>=', period.from)
+ .where('first_seen', '<', period.to)
+ .orderBy('occurrence_count', 'desc')
+ .limit(10)
+ .execute();
- const now = new Date();
- const currentPeriodStart = new Date(now.getTime() - hoursInPeriod * 60 * 60 * 1000);
- const previousPeriodStart = new Date(now.getTime() - hoursInPeriod * 2 * 60 * 60 * 1000);
- const previousPeriodEnd = currentPeriodStart;
+ return groups.map((g) => ({
+ exceptionType: g.exception_type,
+ exceptionMessage: g.exception_message ?? '',
+ occurrenceCount: g.occurrence_count,
+ language: g.language,
+ }));
+ }
- let currentPeriodCount = 0;
- let previousPeriodCount = 0;
+ /**
+ * Security summary: windowed detection totals and top triggered Sigma rules
+ * (raw detection_events, so the most recent hours are never stale like the
+ * continuous aggregates), plus a point-in-time open incident count.
+ */
+ private async calculateSecuritySummary(
+ organizationId: string,
+ period: Period
+ ): Promise {
+ const totalRow = await db
+ .selectFrom('detection_events')
+ .select((eb) => eb.fn.countAll().as('count'))
+ .where('organization_id', '=', organizationId)
+ .where('time', '>=', period.from)
+ .where('time', '<', period.to)
+ .executeTakeFirst();
- try {
-
- // organization-level metrics we must count logs for all projects that
- // belong to the organization and pass those project ids to the reservoir
- const projects = await db
- .selectFrom('projects')
- .select(['id'])
- .where('organization_id', '=', organizationId)
- .execute();
-
- const projectIds = projects.map((p) => p.id);
-
- // If the organization has no projects, return zero counts explicitly
- if (projectIds.length === 0) {
- const trend = this.calculateTrend(0, 0);
- return {
- currentPeriodCount: 0,
- previousPeriodCount: 0,
- trend,
- };
- } else {
- const currentPeriodResult = await reservoir.count({
- projectId: projectIds,
- from: currentPeriodStart,
- to: now,
- toExclusive: true,
- });
- currentPeriodCount = currentPeriodResult.count;
+ const topRules = await db
+ .selectFrom('detection_events')
+ .select((eb) => ['rule_title', 'severity', eb.fn.countAll().as('count')] as const)
+ .where('organization_id', '=', organizationId)
+ .where('time', '>=', period.from)
+ .where('time', '<', period.to)
+ .groupBy(['rule_title', 'severity'])
+ .orderBy('count', 'desc')
+ .limit(5)
+ .execute();
+
+ const openRow = await db
+ .selectFrom('incidents')
+ .select((eb) => eb.fn.countAll().as('count'))
+ .where('organization_id', '=', organizationId)
+ .where('status', 'in', ['open', 'investigating'])
+ .executeTakeFirst();
+
+ return {
+ totalDetections: Number(totalRow?.count ?? 0),
+ topRules: topRules.map((r) => ({
+ ruleTitle: r.rule_title,
+ severity: r.severity,
+ count: Number(r.count),
+ })),
+ openIncidents: Number(openRow?.count ?? 0),
+ };
+ }
+
+ /**
+ * Uptime summary from monitor_uptime_daily. Returns null when the org has
+ * no enabled monitors so the email can skip the section entirely.
+ * Daily buckets only partially overlap a sliding 24h window; that
+ * approximation is acceptable for a digest.
+ */
+ private async calculateUptimeSummary(
+ organizationId: string,
+ period: Period
+ ): Promise {
+ const monitors = await db
+ .selectFrom('monitors')
+ .select(['id', 'name'])
+ .where('organization_id', '=', organizationId)
+ .where('enabled', '=', true)
+ .execute();
+
+ if (monitors.length === 0) {
+ return null;
+ }
- const previousPeriodResult = await reservoir.count({
- projectId: projectIds,
- from: previousPeriodStart,
- to: previousPeriodEnd,
- toExclusive: true,
+ const bucketFrom = new Date(period.from);
+ bucketFrom.setUTCHours(0, 0, 0, 0);
+
+ const rows = await db
+ .selectFrom('monitor_uptime_daily')
+ .select((eb) => [
+ 'monitor_id',
+ eb.fn.sum('successful_checks').as('successful'),
+ eb.fn.sum('total_checks').as('total'),
+ ])
+ .where('organization_id', '=', organizationId)
+ .where('bucket', '>=', bucketFrom)
+ .groupBy('monitor_id')
+ .execute();
+
+ const statsByMonitor = new Map(rows.map((r) => [r.monitor_id, r]));
+
+ let overallSuccessful = 0;
+ let overallTotal = 0;
+ const perMonitor: Array<{ name: string; uptimePct: number }> = [];
+
+ for (const monitor of monitors) {
+ const stats = statsByMonitor.get(monitor.id);
+ const successful = Number(stats?.successful ?? 0);
+ const total = Number(stats?.total ?? 0);
+ overallSuccessful += successful;
+ overallTotal += total;
+
+ if (total > 0) {
+ perMonitor.push({
+ name: monitor.name,
+ uptimePct: Math.round((successful / total) * 10000) / 100,
});
- previousPeriodCount = previousPeriodResult.count;
}
- } catch (error: unknown) {
- const message = error instanceof Error ? error.message : String(error);
- hub.captureLog('error', `[DigestGenerator] Log volume count failed for org ${organizationId}: ${message}`, { error });
- throw error;
}
- const trend = this.calculateTrend(currentPeriodCount, previousPeriodCount);
+ const overallUptimePct =
+ overallTotal > 0 ? Math.round((overallSuccessful / overallTotal) * 10000) / 100 : 100;
+
+ perMonitor.sort((a, b) => a.uptimePct - b.uptimePct);
return {
- currentPeriodCount,
- previousPeriodCount,
- trend,
+ monitorCount: monitors.length,
+ overallUptimePct,
+ worstMonitors: perMonitor.slice(0, 5),
};
}
-
- //current and previous counts
-
+ private async fetchRecipients(
+ organizationId: string,
+ digestConfigId: string
+ ): Promise {
+ const recipients = await db
+ .selectFrom('digest_recipients')
+ .select(['email', 'unsubscribe_token'])
+ .where('organization_id', '=', organizationId)
+ .where('digest_config_id', '=', digestConfigId)
+ .where('subscribed', '=', true)
+ .execute();
+
+ return recipients;
+ }
+
private calculateTrend(current: number, previous: number): string {
if (previous === 0 && current === 0) {
return 'no change';
@@ -204,12 +425,9 @@ export class DigestGeneratorService {
}
}
- //mail
private async sendDigestEmails(
recipients: DigestRecipient[],
- organizationName: string,
- frequency: 'daily' | 'weekly',
- stats: LogVolumeStats
+ report: DigestReportData
): Promise {
const transporter = getEmailTransporter();
@@ -217,22 +435,22 @@ export class DigestGeneratorService {
throw new Error('Email transporter not configured');
}
- const subject = `[LogTide Digest] ${frequency === 'daily' ? 'Daily' : 'Weekly'} Report - ${organizationName}`;
+ const subject = `[LogTide Digest] ${report.frequency === 'daily' ? 'Daily' : 'Weekly'} Report - ${report.organizationName}`;
+ const frontendUrl = this.getFrontendUrl();
-
const emailPromises = recipients.map(async (recipient) => {
- const text = this.generatePlaintextEmail(
- organizationName,
- frequency,
- stats,
- recipient.unsubscribe_token
- );
+ const { html, text } = generateDigestEmail({
+ ...report,
+ unsubscribeUrl: `${frontendUrl}/unsubscribe?token=${recipient.unsubscribe_token}`,
+ dashboardUrl: `${frontendUrl}/dashboard`,
+ });
await transporter.sendMail({
from: `"LogTide" <${config.SMTP_FROM || config.SMTP_USER}>`,
to: recipient.email,
subject,
text,
+ html,
});
hub.captureLog('info', `[DigestGenerator] Email sent to ${recipient.email}`);
@@ -249,53 +467,6 @@ export class DigestGeneratorService {
}
}
- //email template
- private generatePlaintextEmail(
- organizationName: string,
- frequency: 'daily' | 'weekly',
- stats: LogVolumeStats,
- unsubscribeToken: string
- ): string {
- const period = frequency === 'daily' ? 'last 24 hours' : 'last 7 days';
- const frontendUrl = this.getFrontendUrl();
- const unsubscribeUrl = `${frontendUrl}/unsubscribe?token=${unsubscribeToken}`;
-
- let content = `LogTide ${frequency === 'daily' ? 'Daily' : 'Weekly'} Digest\n`;
- content += `Organization: ${organizationName}\n`;
- content += `Period: ${period}\n`;
- content += `\n`;
- content += `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`;
- content += `\n`;
-
- if (stats.currentPeriodCount === 0 && stats.previousPeriodCount === 0) {
- content += ` Log Volume\n`;
- content += `\n`;
- content += `No activity during this period.\n`;
- content += `Your systems have been quiet.\n`;
- } else {
- content += ` Log Volume\n`;
- content += `\n`;
- // Fixed locale: digest content must not depend on the server's locale
- content += `Total logs: ${stats.currentPeriodCount.toLocaleString('en-US')}\n`;
- content += `Trend: ${stats.trend}\n`;
- content += `Previous period: ${stats.previousPeriodCount.toLocaleString('en-US')}\n`;
- }
-
- content += `\n`;
- content += `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`;
- content += `\n`;
- content += `View your dashboard: ${frontendUrl}\n`;
- content += `\n`;
- content += `To unsubscribe from these reports, click:\n`;
- content += `${unsubscribeUrl}\n`;
- content += `\n`;
- content += `—\n`;
- content += `LogTide - observability for your infrastructure\n`;
-
- return content;
- }
-
-
private getFrontendUrl(): string {
return config.FRONTEND_URL || 'http://localhost:3000';
}
diff --git a/packages/backend/src/modules/digests/index.ts b/packages/backend/src/modules/digests/index.ts
new file mode 100644
index 00000000..ec616a70
--- /dev/null
+++ b/packages/backend/src/modules/digests/index.ts
@@ -0,0 +1,5 @@
+export * from './service.js';
+export * from './routes.js';
+export * from './public-routes.js';
+export * from './scheduler.js';
+export * from './generator.js';
diff --git a/packages/backend/src/modules/digests/public-routes.ts b/packages/backend/src/modules/digests/public-routes.ts
new file mode 100644
index 00000000..7a24c244
--- /dev/null
+++ b/packages/backend/src/modules/digests/public-routes.ts
@@ -0,0 +1,45 @@
+/**
+ * Public Digest Routes (#154)
+ *
+ * One-click unsubscribe consumed by the link in digest email footers. No
+ * authentication: the 32-byte random unsubscribe token is the credential
+ * (unique index on digest_recipients.unsubscribe_token, not enumerable).
+ */
+
+import type { FastifyInstance } from 'fastify';
+import { z } from 'zod';
+import { digestsService } from './service.js';
+
+const unsubscribeSchema = z.object({
+ token: z.string().min(1).max(200),
+});
+
+/**
+ * Mask an email for the unsubscribe confirmation page: g***@example.com
+ */
+function maskEmail(email: string): string {
+ const [local, domain] = email.split('@');
+ if (!domain) return '***';
+ const visible = local.slice(0, 1);
+ return `${visible}***@${domain}`;
+}
+
+export async function digestsPublicRoutes(fastify: FastifyInstance) {
+ /**
+ * POST /api/v1/digests/unsubscribe
+ * Marks the recipient behind the token as unsubscribed. Idempotent.
+ */
+ fastify.post('/unsubscribe', async (request, reply) => {
+ const parsed = unsubscribeSchema.safeParse(request.body);
+ if (!parsed.success) {
+ return reply.status(400).send({ error: 'Invalid unsubscribe request' });
+ }
+
+ const result = await digestsService.unsubscribeByToken(parsed.data.token);
+ if (!result) {
+ return reply.status(404).send({ error: 'Invalid or expired unsubscribe link' });
+ }
+
+ return reply.send({ success: true, email: maskEmail(result.email) });
+ });
+}
diff --git a/packages/backend/src/modules/digests/routes.ts b/packages/backend/src/modules/digests/routes.ts
new file mode 100644
index 00000000..d28a8794
--- /dev/null
+++ b/packages/backend/src/modules/digests/routes.ts
@@ -0,0 +1,320 @@
+/**
+ * Digest Configuration API Routes (#154)
+ *
+ * Session-authenticated CRUD for the per-organization digest config and its
+ * recipient list. Reads need membership; writes need owner/admin.
+ */
+
+import type { FastifyInstance } from 'fastify';
+import { z } from 'zod';
+import { digestsService } from './service.js';
+import { authenticate } from '../auth/middleware.js';
+import { OrganizationsService } from '../organizations/service.js';
+import { context } from '@logtide/shared/context';
+import { assertWithinLimit, withLimitLock } from '../../capabilities/index.js';
+import { CapabilityError } from '../../capabilities/errors.js';
+import { auditLogService } from '../audit-log/service.js';
+
+const organizationsService = new OrganizationsService();
+
+// ============================================================================
+// VALIDATION SCHEMAS
+// ============================================================================
+
+const orgQuerySchema = z.object({
+ organizationId: z.string().uuid('organizationId must be a valid uuid'),
+});
+
+const configBodySchema = z
+ .object({
+ frequency: z.enum(['daily', 'weekly']),
+ deliveryHour: z.number().int().min(0).max(23),
+ deliveryDayOfWeek: z.number().int().min(0).max(6).nullish(),
+ enabled: z.boolean(),
+ })
+ .refine((body) => body.frequency !== 'weekly' || body.deliveryDayOfWeek != null, {
+ message: 'deliveryDayOfWeek is required for weekly digests',
+ path: ['deliveryDayOfWeek'],
+ });
+
+const addRecipientSchema = z.object({
+ email: z.string().email('Must be a valid email address'),
+});
+
+const recipientParamsSchema = z.object({
+ id: z.string().uuid(),
+});
+
+// ============================================================================
+// HELPERS
+// ============================================================================
+
+async function checkOrganizationMembership(
+ userId: string,
+ organizationId: string
+): Promise {
+ const organizations = await organizationsService.getUserOrganizations(userId);
+ return organizations.some((org) => org.id === organizationId);
+}
+
+async function checkAdminRole(userId: string, organizationId: string): Promise {
+ return organizationsService.isOwnerOrAdmin(organizationId, userId);
+}
+
+// ============================================================================
+// ROUTES
+// ============================================================================
+
+export async function digestsRoutes(fastify: FastifyInstance) {
+ // All routes require authentication
+ fastify.addHook('onRequest', authenticate);
+
+ /**
+ * GET /api/v1/digests/config
+ * Current digest config + recipients for an organization (members)
+ */
+ fastify.get('/config', async (request: any, reply) => {
+ try {
+ const { organizationId } = orgQuerySchema.parse(request.query);
+
+ const isMember = await checkOrganizationMembership(request.user.id, organizationId);
+ if (!isMember) {
+ return reply.status(403).send({ error: 'Not a member of this organization' });
+ }
+
+ const config = await digestsService.getConfig(organizationId);
+ const recipients = await digestsService.listRecipients(organizationId);
+
+ return reply.send({ config: config ?? null, recipients });
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return reply.status(400).send({ error: 'Validation error', details: error.errors });
+ }
+ console.error(error, 'Failed to get digest config');
+ return reply.status(500).send({ error: 'Failed to get digest config' });
+ }
+ });
+
+ /**
+ * PUT /api/v1/digests/config
+ * Create or update the organization digest config (admin only)
+ */
+ fastify.put('/config', async (request: any, reply) => {
+ try {
+ const { organizationId } = orgQuerySchema.parse(request.query);
+ const body = configBodySchema.parse(request.body);
+
+ const isMember = await checkOrganizationMembership(request.user.id, organizationId);
+ if (!isMember) {
+ return reply.status(403).send({ error: 'Not a member of this organization' });
+ }
+
+ const isAdmin = await checkAdminRole(request.user.id, organizationId);
+ if (!isAdmin) {
+ return reply.status(403).send({ error: 'Only admins can configure digests' });
+ }
+
+ const config = await digestsService.upsertConfig(organizationId, {
+ frequency: body.frequency,
+ deliveryHour: body.deliveryHour,
+ deliveryDayOfWeek: body.deliveryDayOfWeek,
+ enabled: body.enabled,
+ });
+
+ await auditLogService.record({
+ action: 'digest.config_updated',
+ target: { type: 'digest_config', id: config.id },
+ organizationId,
+ metadata: {
+ frequency: config.frequency,
+ deliveryHour: config.delivery_hour,
+ enabled: config.enabled,
+ },
+ });
+
+ return reply.send({ config });
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return reply.status(400).send({ error: 'Validation error', details: error.errors });
+ }
+ console.error(error, 'Failed to save digest config');
+ return reply.status(500).send({ error: 'Failed to save digest config' });
+ }
+ });
+
+ /**
+ * DELETE /api/v1/digests/config
+ * Remove the digest config (and its recipients via cascade) (admin only)
+ */
+ fastify.delete('/config', async (request: any, reply) => {
+ try {
+ const { organizationId } = orgQuerySchema.parse(request.query);
+
+ const isMember = await checkOrganizationMembership(request.user.id, organizationId);
+ if (!isMember) {
+ return reply.status(403).send({ error: 'Not a member of this organization' });
+ }
+
+ const isAdmin = await checkAdminRole(request.user.id, organizationId);
+ if (!isAdmin) {
+ return reply.status(403).send({ error: 'Only admins can configure digests' });
+ }
+
+ const deleted = await digestsService.deleteConfig(organizationId);
+ if (!deleted) {
+ return reply.status(404).send({ error: 'Digest config not found' });
+ }
+
+ await auditLogService.record({
+ action: 'digest.config_deleted',
+ target: { type: 'digest_config', id: organizationId },
+ organizationId,
+ });
+
+ return reply.status(204).send();
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return reply.status(400).send({ error: 'Validation error', details: error.errors });
+ }
+ console.error(error, 'Failed to delete digest config');
+ return reply.status(500).send({ error: 'Failed to delete digest config' });
+ }
+ });
+
+ /**
+ * POST /api/v1/digests/recipients
+ * Add a recipient (admin only, capability-limited)
+ */
+ fastify.post('/recipients', async (request: any, reply) => {
+ try {
+ const { organizationId } = orgQuerySchema.parse(request.query);
+ const body = addRecipientSchema.parse(request.body);
+
+ const isMember = await checkOrganizationMembership(request.user.id, organizationId);
+ if (!isMember) {
+ return reply.status(403).send({ error: 'Not a member of this organization' });
+ }
+
+ const isAdmin = await checkAdminRole(request.user.id, organizationId);
+ if (!isAdmin) {
+ return reply.status(403).send({ error: 'Only admins can manage digest recipients' });
+ }
+
+ const recipient = await withLimitLock(organizationId, 'digests.max_recipients', async () => {
+ await context.runAsSystem('digests:recipient-limit-check', async () => {
+ await context.with({ organizationId }, async () => {
+ const count = await digestsService.countRecipients(organizationId);
+ await assertWithinLimit('digests.max_recipients', count);
+ });
+ });
+
+ return digestsService.addRecipient(organizationId, body.email);
+ });
+
+ await auditLogService.record({
+ action: 'digest.recipient_added',
+ target: { type: 'digest_recipient', id: recipient.id },
+ organizationId,
+ metadata: { email: recipient.email },
+ });
+
+ return reply.status(201).send({ recipient });
+ } catch (error) {
+ if (error instanceof CapabilityError) {
+ throw error;
+ }
+ if (error instanceof z.ZodError) {
+ return reply.status(400).send({ error: 'Validation error', details: error.errors });
+ }
+ if (error instanceof Error && error.message.includes('not configured')) {
+ return reply.status(409).send({ error: 'Configure the digest before adding recipients' });
+ }
+ if (error instanceof Error && (error.message.includes('unique') || error.message.includes('duplicate'))) {
+ return reply.status(409).send({ error: 'This email is already a recipient' });
+ }
+ console.error(error, 'Failed to add digest recipient');
+ return reply.status(500).send({ error: 'Failed to add recipient' });
+ }
+ });
+
+ /**
+ * DELETE /api/v1/digests/recipients/:id
+ * Remove a recipient (admin only)
+ */
+ fastify.delete('/recipients/:id', async (request: any, reply) => {
+ try {
+ const { organizationId } = orgQuerySchema.parse(request.query);
+ const { id } = recipientParamsSchema.parse(request.params);
+
+ const isMember = await checkOrganizationMembership(request.user.id, organizationId);
+ if (!isMember) {
+ return reply.status(403).send({ error: 'Not a member of this organization' });
+ }
+
+ const isAdmin = await checkAdminRole(request.user.id, organizationId);
+ if (!isAdmin) {
+ return reply.status(403).send({ error: 'Only admins can manage digest recipients' });
+ }
+
+ const removed = await digestsService.removeRecipient(organizationId, id);
+ if (!removed) {
+ return reply.status(404).send({ error: 'Recipient not found' });
+ }
+
+ await auditLogService.record({
+ action: 'digest.recipient_removed',
+ target: { type: 'digest_recipient', id },
+ organizationId,
+ });
+
+ return reply.status(204).send();
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return reply.status(400).send({ error: 'Validation error', details: error.errors });
+ }
+ console.error(error, 'Failed to remove digest recipient');
+ return reply.status(500).send({ error: 'Failed to remove recipient' });
+ }
+ });
+
+ /**
+ * POST /api/v1/digests/recipients/:id/resubscribe
+ * Re-subscribe a recipient who opted out (admin only)
+ */
+ fastify.post('/recipients/:id/resubscribe', async (request: any, reply) => {
+ try {
+ const { organizationId } = orgQuerySchema.parse(request.query);
+ const { id } = recipientParamsSchema.parse(request.params);
+
+ const isMember = await checkOrganizationMembership(request.user.id, organizationId);
+ if (!isMember) {
+ return reply.status(403).send({ error: 'Not a member of this organization' });
+ }
+
+ const isAdmin = await checkAdminRole(request.user.id, organizationId);
+ if (!isAdmin) {
+ return reply.status(403).send({ error: 'Only admins can manage digest recipients' });
+ }
+
+ const recipient = await digestsService.resubscribeRecipient(organizationId, id);
+ if (!recipient) {
+ return reply.status(404).send({ error: 'Recipient not found' });
+ }
+
+ await auditLogService.record({
+ action: 'digest.recipient_resubscribed',
+ target: { type: 'digest_recipient', id },
+ organizationId,
+ metadata: { email: recipient.email },
+ });
+
+ return reply.send({ recipient });
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return reply.status(400).send({ error: 'Validation error', details: error.errors });
+ }
+ console.error(error, 'Failed to resubscribe digest recipient');
+ return reply.status(500).send({ error: 'Failed to resubscribe recipient' });
+ }
+ });
+}
diff --git a/packages/backend/src/modules/digests/scheduler.ts b/packages/backend/src/modules/digests/scheduler.ts
index 2dc163ac..540e3ba7 100644
--- a/packages/backend/src/modules/digests/scheduler.ts
+++ b/packages/backend/src/modules/digests/scheduler.ts
@@ -1,17 +1,21 @@
/**
* Digest Scheduler
*
- * Reads all active digest configs from the database and registers them as
- * repeating cron jobs via the queue abstraction. Called once at worker startup.
+ * Registers a single static hourly cron job ("digest-dispatch") at worker
+ * startup. The dispatch job (src/queue/jobs/digest-dispatch.ts) scans the
+ * active digest configs each hour and enqueues digest-generation jobs for the
+ * ones due at that UTC hour.
*
- * Both BullMQ (Redis) and graphile-worker (PostgreSQL) backends are supported
- * through the ICronRegistry interface — this service never knows which is active.
+ * Earlier versions registered one cron job per organization at boot, but
+ * graphile-worker cron items cannot change while the runner is up, so config
+ * CRUD would have required a worker restart. A static dispatch cron plus a
+ * due-check keeps schedule changes live on both queue backends (BullMQ and
+ * graphile-worker) through the ICronRegistry interface - this service never
+ * knows which is active.
*/
-import { db } from '../../database/connection.js';
import { getCronRegistry } from '../../queue/queue-factory.js';
import { hub } from '@logtide/core';
-import type { CronJobDefinition } from '../../queue/abstractions/types.js';
export interface DigestJobPayload {
organizationId: string;
@@ -19,58 +23,24 @@ export interface DigestJobPayload {
frequency: 'daily' | 'weekly';
}
-export class DigestScheduler {
- /**
- * Register all active digest configs as repeating cron jobs.
- */
- async registerAllDigests(): Promise {
- const configs = await db
- .selectFrom('digest_configs')
- .select(['id', 'organization_id', 'frequency', 'delivery_hour', 'delivery_day_of_week'])
- .where('enabled', '=', true)
- .execute();
-
- if (configs.length === 0) {
- hub.captureLog('info', '[DigestScheduler] No active digest configs found');
- return;
- }
-
- const items: CronJobDefinition[] = configs.map((config) => ({
- task: 'digest-generation',
- cronExpression: this.buildCronExpression(
- config.frequency as 'daily' | 'weekly',
- config.delivery_hour,
- config.delivery_day_of_week
- ),
- payload: {
- organizationId: config.organization_id,
- digestConfigId: config.id,
- frequency: config.frequency,
- } satisfies DigestJobPayload,
- // Stable identifier per org — prevents duplicate schedules on restart
- identifier: `digest:${config.organization_id}`,
- }));
-
- await getCronRegistry('digest-generation').registerCronJobs(items);
- hub.captureLog('info', `[DigestScheduler] Registered ${items.length} digest schedule(s)`);
- }
+export const DIGEST_DISPATCH_CRON = '0 * * * *'; // every hour on the hour
+export class DigestScheduler {
/**
- * Build a standard 5-field cron expression from a digest config.
- *
- * Daily: "0 8 * * *" — every day at delivery_hour
- * Weekly: "0 8 * * 1" — every week on delivery_day_of_week
+ * Register the hourly digest-dispatch cron job. Called once at worker boot.
*/
- private buildCronExpression(
- frequency: 'daily' | 'weekly',
- deliveryHour: number,
- deliveryDayOfWeek: number | null
- ): string {
- if (frequency === 'daily') {
- return `0 ${deliveryHour} * * *`;
- }
- // Weekly — delivery_day_of_week is guaranteed non-null by the DB constraint
- return `0 ${deliveryHour} * * ${deliveryDayOfWeek}`;
+ async registerDispatchCron(): Promise {
+ await getCronRegistry('digest-dispatch').registerCronJobs([
+ {
+ task: 'digest-dispatch',
+ cronExpression: DIGEST_DISPATCH_CRON,
+ payload: {},
+ // Stable identifier - prevents duplicate schedules on restart
+ identifier: 'digest-dispatch',
+ },
+ ]);
+
+ hub.captureLog('info', '[DigestScheduler] Registered hourly digest dispatch cron');
}
}
diff --git a/packages/backend/src/modules/digests/service.ts b/packages/backend/src/modules/digests/service.ts
new file mode 100644
index 00000000..30f99ca3
--- /dev/null
+++ b/packages/backend/src/modules/digests/service.ts
@@ -0,0 +1,164 @@
+/**
+ * Digests Service
+ *
+ * CRUD for digest_configs (one per organization) and digest_recipients,
+ * plus token-based unsubscribe used by the public endpoint.
+ */
+
+import crypto from 'crypto';
+import { db } from '../../database/connection.js';
+import type { DigestFrequency } from '../../database/types.js';
+
+export interface DigestConfigInput {
+ frequency: DigestFrequency;
+ deliveryHour: number;
+ deliveryDayOfWeek?: number | null;
+ enabled: boolean;
+}
+
+export class DigestsService {
+ async getConfig(organizationId: string) {
+ return db
+ .selectFrom('digest_configs')
+ .select([
+ 'id',
+ 'frequency',
+ 'delivery_hour',
+ 'delivery_day_of_week',
+ 'enabled',
+ 'last_sent_at',
+ 'created_at',
+ 'updated_at',
+ ])
+ .where('organization_id', '=', organizationId)
+ .executeTakeFirst();
+ }
+
+ async upsertConfig(organizationId: string, input: DigestConfigInput) {
+ const values = {
+ organization_id: organizationId,
+ frequency: input.frequency,
+ delivery_hour: input.deliveryHour,
+ delivery_day_of_week: input.frequency === 'weekly' ? input.deliveryDayOfWeek ?? null : null,
+ enabled: input.enabled,
+ };
+
+ return db
+ .insertInto('digest_configs')
+ .values(values)
+ .onConflict((oc) =>
+ oc.column('organization_id').doUpdateSet({
+ frequency: values.frequency,
+ delivery_hour: values.delivery_hour,
+ delivery_day_of_week: values.delivery_day_of_week,
+ enabled: values.enabled,
+ updated_at: new Date(),
+ })
+ )
+ .returning([
+ 'id',
+ 'frequency',
+ 'delivery_hour',
+ 'delivery_day_of_week',
+ 'enabled',
+ 'last_sent_at',
+ 'created_at',
+ 'updated_at',
+ ])
+ .executeTakeFirstOrThrow();
+ }
+
+ async deleteConfig(organizationId: string): Promise {
+ const result = await db
+ .deleteFrom('digest_configs')
+ .where('organization_id', '=', organizationId)
+ .executeTakeFirst();
+ return result.numDeletedRows > 0n;
+ }
+
+ async listRecipients(organizationId: string) {
+ return db
+ .selectFrom('digest_recipients')
+ .select(['id', 'email', 'user_id', 'subscribed', 'created_at'])
+ .where('organization_id', '=', organizationId)
+ .orderBy('created_at', 'asc')
+ .execute();
+ }
+
+ async countRecipients(organizationId: string): Promise {
+ const row = await db
+ .selectFrom('digest_recipients')
+ .select((eb) => eb.fn.countAll().as('count'))
+ .where('organization_id', '=', organizationId)
+ .executeTakeFirst();
+ return Number(row?.count ?? 0);
+ }
+
+ /**
+ * Add a recipient to the organization's digest. The digest config must
+ * exist first; recipients hang off it.
+ */
+ async addRecipient(organizationId: string, email: string, userId?: string | null) {
+ const config = await this.getConfig(organizationId);
+ if (!config) {
+ throw new Error('Digest is not configured for this organization');
+ }
+
+ return db
+ .insertInto('digest_recipients')
+ .values({
+ organization_id: organizationId,
+ digest_config_id: config.id,
+ email,
+ user_id: userId ?? null,
+ unsubscribe_token: crypto.randomBytes(32).toString('base64url'),
+ })
+ .returning(['id', 'email', 'user_id', 'subscribed', 'created_at'])
+ .executeTakeFirstOrThrow();
+ }
+
+ async removeRecipient(organizationId: string, recipientId: string): Promise {
+ const result = await db
+ .deleteFrom('digest_recipients')
+ .where('organization_id', '=', organizationId)
+ .where('id', '=', recipientId)
+ .executeTakeFirst();
+ return result.numDeletedRows > 0n;
+ }
+
+ /**
+ * Re-subscribe a recipient who unsubscribed. Rotates the unsubscribe token
+ * so links in previously delivered emails cannot flip the state back.
+ */
+ async resubscribeRecipient(organizationId: string, recipientId: string) {
+ return db
+ .updateTable('digest_recipients')
+ .set({
+ subscribed: true,
+ unsubscribe_token: crypto.randomBytes(32).toString('base64url'),
+ updated_at: new Date(),
+ })
+ .where('organization_id', '=', organizationId)
+ .where('id', '=', recipientId)
+ .returning(['id', 'email', 'user_id', 'subscribed', 'created_at'])
+ .executeTakeFirst();
+ }
+
+ /**
+ * One-click unsubscribe from an email link. The opaque token IS the
+ * credential; no authentication involved.
+ */
+ async unsubscribeByToken(token: string): Promise<{ email: string } | null> {
+ // tenant-scope-ok: public unsubscribe path, the unique random token is the credential
+ const row = await db
+ .updateTable('digest_recipients')
+ .set({ subscribed: false, updated_at: new Date() })
+ .where('unsubscribe_token', '=', token)
+ .returning(['email'])
+ .executeTakeFirst();
+
+ return row ?? null;
+ }
+}
+
+export const digestsService = new DigestsService();
diff --git a/packages/backend/src/modules/exceptions/detection.ts b/packages/backend/src/modules/exceptions/detection.ts
index da2f3405..1f7b70c9 100644
--- a/packages/backend/src/modules/exceptions/detection.ts
+++ b/packages/backend/src/modules/exceptions/detection.ts
@@ -13,6 +13,11 @@ import type { ExceptionLanguage, StructuredException } from '@logtide/shared';
// Library patterns for detecting vendor/library code
const LIBRARY_PATTERNS = [
/node_modules/,
+ // Node.js runtime frames (node:internal/..., node:events, ...). These are not
+ // application code, and because async stack traces vary in which internal
+ // frames they include, treating them as app code makes the same logical error
+ // fingerprint differently and split into several error groups.
+ /^node:/,
/vendor\//,
/site-packages/,
/\.cargo/,
diff --git a/packages/backend/src/modules/exceptions/fingerprint-service.ts b/packages/backend/src/modules/exceptions/fingerprint-service.ts
index aafd6603..e60cf493 100644
--- a/packages/backend/src/modules/exceptions/fingerprint-service.ts
+++ b/packages/backend/src/modules/exceptions/fingerprint-service.ts
@@ -43,6 +43,20 @@ export class FingerprintService {
return crypto.createHash('sha256').update(input).digest('hex');
}
+ /**
+ * The throw site: raw "file:function" of the first application frame, or null
+ * when there is none. Used as the coarse-grouping key's frame component (see
+ * migration 055's logtide_merge_key). Kept raw (not path-normalized) so the app
+ * and the SQL function agree without duplicating normalization.
+ */
+ static topAppFrame(parsedException: ParsedException): string | null {
+ const frame = parsedException.frames.find((f) => f.isAppCode);
+ if (!frame) return null;
+ const file = frame.originalFile || frame.filePath;
+ const func = frame.originalFunction || frame.functionName || '';
+ return `${file}:${func}`;
+ }
+
/**
* Default normalization when parser is not available
*/
diff --git a/packages/backend/src/modules/exceptions/routes.ts b/packages/backend/src/modules/exceptions/routes.ts
index a11a2fed..e7a78ff8 100644
--- a/packages/backend/src/modules/exceptions/routes.ts
+++ b/packages/backend/src/modules/exceptions/routes.ts
@@ -567,6 +567,85 @@ export async function exceptionsRoutes(fastify: FastifyInstance) {
}
);
+ /**
+ * GET /api/v1/error-groups/:id/duplicates
+ * Other groups with the same exception type and message (merge candidates).
+ */
+ fastify.get(
+ '/api/v1/error-groups/:id/duplicates',
+ async (request: any, reply) => {
+ try {
+ const { id } = z.object({ id: z.string().uuid() }).parse(request.params);
+ const { organizationId } = z
+ .object({ organizationId: z.string().uuid() })
+ .parse(request.query);
+
+ const isMember = await checkOrganizationMembership(request.user.id, organizationId);
+ if (!isMember) {
+ return reply.status(403).send({ error: 'You are not a member of this organization' });
+ }
+
+ const group = await exceptionService.getErrorGroupById(id);
+ if (!group || group.organizationId !== organizationId) {
+ return reply.status(404).send({ error: 'Error group not found' });
+ }
+
+ const duplicates = await exceptionService.findDuplicateErrorGroups(id, organizationId);
+ return reply.send({ duplicates });
+ } catch (error: any) {
+ if (error instanceof z.ZodError) {
+ return reply.status(400).send({ error: 'Validation error', details: error.errors });
+ }
+ console.error('Error finding duplicate error groups:', error);
+ return reply.status(500).send({ error: 'Failed to find duplicate error groups' });
+ }
+ }
+ );
+
+ /**
+ * POST /api/v1/error-groups/:id/merge
+ * Merge the given source groups into this group.
+ */
+ fastify.post(
+ '/api/v1/error-groups/:id/merge',
+ {
+ config: { rateLimit: { max: 20, timeWindow: '1 minute' } },
+ },
+ async (request: any, reply) => {
+ try {
+ const { id } = z.object({ id: z.string().uuid() }).parse(request.params);
+ const body = z
+ .object({
+ organizationId: z.string().uuid(),
+ sourceIds: z.array(z.string().uuid()).min(1).max(100),
+ })
+ .parse(request.body);
+
+ const isMember = await checkOrganizationMembership(request.user.id, body.organizationId);
+ if (!isMember) {
+ return reply.status(403).send({ error: 'You are not a member of this organization' });
+ }
+
+ const group = await exceptionService.getErrorGroupById(id);
+ if (!group || group.organizationId !== body.organizationId) {
+ return reply.status(404).send({ error: 'Error group not found' });
+ }
+
+ const merged = await exceptionService.mergeErrorGroups(id, body.sourceIds, body.organizationId);
+ if (!merged) {
+ return reply.status(404).send({ error: 'Error group not found' });
+ }
+ return reply.send(merged);
+ } catch (error: any) {
+ if (error instanceof z.ZodError) {
+ return reply.status(400).send({ error: 'Validation error', details: error.errors });
+ }
+ console.error('Error merging error groups:', error);
+ return reply.status(500).send({ error: 'Failed to merge error groups' });
+ }
+ }
+ );
+
/**
* GET /api/v1/error-groups/:id/trend
* Get error group occurrence trend (time-series)
diff --git a/packages/backend/src/modules/exceptions/service.ts b/packages/backend/src/modules/exceptions/service.ts
index 8dd693c2..77333ac9 100644
--- a/packages/backend/src/modules/exceptions/service.ts
+++ b/packages/backend/src/modules/exceptions/service.ts
@@ -26,7 +26,7 @@ export class ExceptionService {
* The trigger will automatically update/create the error group
*/
async createException(params: CreateExceptionParams): Promise {
- const { organizationId, projectId, logId, parsedData, fingerprint } = params;
+ const { organizationId, projectId, logId, parsedData, fingerprint, service, topFrame } = params;
return await this.db.transaction().execute(async (trx) => {
const exception = await trx
@@ -41,6 +41,8 @@ export class ExceptionService {
fingerprint,
raw_stack_trace: parsedData.rawStackTrace,
frame_count: parsedData.frames.length,
+ service: service ?? null,
+ top_frame: topFrame ?? null,
})
.returning('id')
.executeTakeFirstOrThrow();
@@ -384,6 +386,154 @@ export class ExceptionService {
return this.getErrorGroupById(groupId);
}
+ /**
+ * Find other error groups in the same org that share this group's exception
+ * type and message (the shape a fingerprint split produces). Candidates to
+ * merge into this group.
+ */
+ async findDuplicateErrorGroups(
+ groupId: string,
+ organizationId: string
+ ): Promise> {
+ const target = await this.db
+ .selectFrom('error_groups')
+ .select(['exception_type', 'exception_message', 'project_id'])
+ .where('id', '=', groupId)
+ .where('organization_id', '=', organizationId)
+ .executeTakeFirst();
+
+ if (!target) return [];
+
+ let query = this.db
+ .selectFrom('error_groups')
+ .select(['id', 'occurrence_count', 'first_seen', 'last_seen'])
+ .where('organization_id', '=', organizationId)
+ .where('exception_type', '=', target.exception_type)
+ .where('id', '!=', groupId);
+
+ // Error groups are unique per (org, project, fingerprint), so the same
+ // type+message legitimately exists in sibling projects; only surface merge
+ // candidates from the target's own project.
+ query =
+ target.project_id === null
+ ? query.where('project_id', 'is', null)
+ : query.where('project_id', '=', target.project_id);
+
+ query =
+ target.exception_message === null
+ ? query.where('exception_message', 'is', null)
+ : query.where('exception_message', '=', target.exception_message);
+
+ const rows = await query.execute();
+ return rows.map((r) => ({
+ id: r.id,
+ occurrenceCount: Number(r.occurrence_count),
+ firstSeen: r.first_seen as Date,
+ lastSeen: r.last_seen as Date,
+ }));
+ }
+
+ /**
+ * Merge source error groups into a target group: reassign their exceptions to
+ * the target fingerprint, fold their counts / services / first-last seen into
+ * the target, then delete the now-empty source groups. All org-scoped and
+ * transactional.
+ */
+ async mergeErrorGroups(
+ targetId: string,
+ sourceIds: string[],
+ organizationId: string
+ ): Promise {
+ await this.db.transaction().execute(async (trx) => {
+ const target = await trx
+ .selectFrom('error_groups')
+ .select([
+ 'id',
+ 'fingerprint',
+ 'project_id',
+ 'occurrence_count',
+ 'affected_services',
+ 'first_seen',
+ 'last_seen',
+ ])
+ .where('id', '=', targetId)
+ .where('organization_id', '=', organizationId)
+ .executeTakeFirst();
+
+ if (!target) return;
+
+ // Sources must live in the target's own project: the same fingerprint can
+ // exist in sibling projects (unique per org+project+fingerprint), so an
+ // org-only filter could fold in, and delete, another project's group.
+ let sourcesQuery = trx
+ .selectFrom('error_groups')
+ .select(['id', 'fingerprint', 'occurrence_count', 'affected_services', 'first_seen', 'last_seen'])
+ .where('organization_id', '=', organizationId)
+ .where('id', 'in', sourceIds)
+ .where('id', '!=', targetId);
+ sourcesQuery =
+ target.project_id === null
+ ? sourcesQuery.where('project_id', 'is', null)
+ : sourcesQuery.where('project_id', '=', target.project_id);
+ const sources = await sourcesQuery.execute();
+
+ if (sources.length === 0) return;
+
+ const sourceFingerprints = sources.map((s) => s.fingerprint);
+
+ // Point the merged-in exceptions at the target's fingerprint so trend/logs
+ // queries for the target include them. Scope by project too: a sibling
+ // project's exceptions can share a fingerprint string and must not be
+ // retagged by this merge.
+ let retag = trx
+ .updateTable('exceptions')
+ .set({ fingerprint: target.fingerprint })
+ .where('organization_id', '=', organizationId)
+ .where('fingerprint', 'in', sourceFingerprints);
+ retag =
+ target.project_id === null
+ ? retag.where('project_id', 'is', null)
+ : retag.where('project_id', '=', target.project_id);
+ await retag.execute();
+
+ const addOccurrences = sources.reduce((sum, s) => sum + Number(s.occurrence_count), 0);
+ const mergedServices = Array.from(
+ new Set([
+ ...((target.affected_services as string[] | null) || []),
+ ...sources.flatMap((s) => (s.affected_services as string[] | null) || []),
+ ])
+ );
+ const times = [target, ...sources];
+ const firstSeen = times
+ .map((g) => new Date(g.first_seen as unknown as string))
+ .reduce((a, b) => (a < b ? a : b));
+ const lastSeen = times
+ .map((g) => new Date(g.last_seen as unknown as string))
+ .reduce((a, b) => (a > b ? a : b));
+
+ await trx
+ .updateTable('error_groups')
+ .set({
+ occurrence_count: Number(target.occurrence_count) + addOccurrences,
+ affected_services: mergedServices,
+ first_seen: firstSeen,
+ last_seen: lastSeen,
+ updated_at: new Date(),
+ })
+ .where('id', '=', targetId)
+ .where('organization_id', '=', organizationId)
+ .execute();
+
+ await trx
+ .deleteFrom('error_groups')
+ .where('organization_id', '=', organizationId)
+ .where('id', 'in', sources.map((s) => s.id))
+ .execute();
+ });
+
+ return this.getErrorGroupById(targetId);
+ }
+
/**
* Get error group trend (time-series data)
*/
diff --git a/packages/backend/src/modules/exceptions/types.ts b/packages/backend/src/modules/exceptions/types.ts
index 05569a9c..ffdb487b 100644
--- a/packages/backend/src/modules/exceptions/types.ts
+++ b/packages/backend/src/modules/exceptions/types.ts
@@ -47,4 +47,15 @@ export interface CreateExceptionParams {
logId: string;
parsedData: ParsedException;
fingerprint: string;
+ /**
+ * Service that emitted the error log. Carried on the exception row so the
+ * error-group trigger can attribute the service on every storage engine,
+ * not just TimescaleDB (where logs live in Postgres).
+ */
+ service?: string | null;
+ /**
+ * Raw "file:function" of the first app frame (throw site). Carried so the
+ * trigger can compute the group's coarse merge key for auto-merge.
+ */
+ topFrame?: string | null;
}
diff --git a/packages/backend/src/modules/ingestion/service.ts b/packages/backend/src/modules/ingestion/service.ts
index a3f814e7..b0dc425d 100644
--- a/packages/backend/src/modules/ingestion/service.ts
+++ b/packages/backend/src/modules/ingestion/service.ts
@@ -17,6 +17,7 @@ import { hooks, HookExecutionError } from '../../hooks/index.js';
import type { BeforeIngestContext } from '../../hooks/index.js';
import { hub } from '@logtide/core';
import { isInternalLoggingEnabled } from '../../utils/internal-logger.js';
+import { createSkewTracker } from './skew.js';
export interface IngestRejection {
/** Index of the record in the submitted batch. */
@@ -156,10 +157,13 @@ export class IngestionService {
// Convert logs to reservoir LogRecord format
// Note: reservoir handles null byte sanitization internally
+ // Clock skew (#279): observed inside the existing map so a large batch costs
+ // one extra subtraction per record and one Date.now() per batch.
+ const skewTracker = createSkewTracker(Date.now());
let records: BeforeIngestContext['records'] = logs.map((log) => {
// Extract hostname if not already set in metadata
const hostname = log.metadata?.hostname || extractHostname(log);
-
+
const metadata = {
...log.metadata,
...(hostname && { hostname }),
@@ -167,8 +171,11 @@ export class IngestionService {
const hasMetadata = Object.keys(metadata).length > 0;
+ const time = typeof log.time === 'string' ? new Date(log.time) : log.time;
+ skewTracker.observe(time);
+
return {
- time: typeof log.time === 'string' ? new Date(log.time) : log.time,
+ time,
projectId,
service: sanitizeForPostgres(log.service),
level: log.level as ReservoirLogLevel,
@@ -180,6 +187,20 @@ export class IngestionService {
};
});
+ // Clock skew signal (#279). Fire-and-forget, never rejects: the records above
+ // are written unchanged. Guarded on organizationId like the other ingestion
+ // health counters, since anonymous ingestion has no org to attribute to.
+ const skew = skewTracker.summary();
+ if (skew && organizationId) {
+ metering.record({
+ type: 'ingestion.timestamp_skew',
+ quantity: skew.count,
+ organizationId,
+ projectId,
+ metadata: { maxPastMs: skew.maxPastMs, maxFutureMs: skew.maxFutureMs },
+ });
+ }
+
// Lifecycle hook (#216): last interception point before the reservoir
// write. Hooks may reject (throw) or mutate/replace `records`.
// hasHandlers guard keeps the OSS no-hooks path at zero overhead
diff --git a/packages/backend/src/modules/ingestion/skew.ts b/packages/backend/src/modules/ingestion/skew.ts
new file mode 100644
index 00000000..5a7922b1
--- /dev/null
+++ b/packages/backend/src/modules/ingestion/skew.ts
@@ -0,0 +1,73 @@
+/**
+ * Ingestion clock skew detection (#279).
+ *
+ * A log whose client-supplied `time` sits far from the server clock is stored
+ * and is visible in the UI, but threshold alert rules count logs in
+ * [now - time_window, now] and so can never see it. The largest configurable
+ * window is 24h (alerts/routes.ts), which is where the past threshold comes
+ * from: past that point, no threshold rule in the product can match the log.
+ *
+ * Observation only. Skewed records are written unchanged.
+ *
+ * Always on: these thresholds are load-bearing product invariants, not
+ * tunable knobs, so they are hardcoded rather than read from config. There is
+ * no "disable" state.
+ */
+
+/** 24h: the largest alert `time_window` (alerts/routes.ts). Past this point,
+ * no threshold rule in the product can ever count the log. Derived, not guessed. */
+const PAST_THRESHOLD_MS = 86400000;
+
+/** 5m: room for NTP jitter. */
+const FUTURE_THRESHOLD_MS = 300000;
+
+export interface SkewSummary {
+ /** Number of skewed records in the batch. */
+ count: number;
+ /** Worst past delta seen, in ms. 0 when nothing was skewed into the past. */
+ maxPastMs: number;
+ /** Worst future delta seen, in ms. 0 when nothing was skewed into the future. */
+ maxFutureMs: number;
+}
+
+export interface SkewTracker {
+ observe(time: Date | undefined): void;
+ summary(): SkewSummary | null;
+}
+
+/**
+ * Single-pass tracker. `now` is read once per batch by the caller so that a
+ * large batch is measured against one instant, not a drifting one.
+ */
+export function createSkewTracker(now: number): SkewTracker {
+ let count = 0;
+ let maxPastMs = 0;
+ let maxFutureMs = 0;
+
+ return {
+ observe(time: Date | undefined): void {
+ if (!time) return;
+
+ // NaN for an Invalid Date. Both comparisons below are false for NaN, so a
+ // malformed timestamp is never miscounted as skew: that is a separate
+ // problem and counting it here would make this signal dishonest.
+ const delta = now - time.getTime();
+
+ if (delta > PAST_THRESHOLD_MS) {
+ count++;
+ if (delta > maxPastMs) maxPastMs = delta;
+ return;
+ }
+
+ if (delta < -FUTURE_THRESHOLD_MS) {
+ count++;
+ const ahead = -delta;
+ if (ahead > maxFutureMs) maxFutureMs = ahead;
+ }
+ },
+
+ summary(): SkewSummary | null {
+ return count > 0 ? { count, maxPastMs, maxFutureMs } : null;
+ },
+ };
+}
diff --git a/packages/backend/src/modules/metering/recorder.ts b/packages/backend/src/modules/metering/recorder.ts
index 76b8feec..84485b04 100644
--- a/packages/backend/src/modules/metering/recorder.ts
+++ b/packages/backend/src/modules/metering/recorder.ts
@@ -29,7 +29,15 @@ export class MeteringRecorder {
}
record(event: MeteringEvent): void {
- if (!config.METERING_ENABLED) return;
+ // METERING_ENABLED gates usage/resource metering (billing-shaped). The
+ // `ingestion.*` types are operational health counters, not usage: they are
+ // already excluded from tenant usage breakdowns by the `NOT LIKE
+ // 'ingestion.%'` filter in breakdown.ts. A billing toggle must not be able
+ // to silently blind operators to ingestion health (including the
+ // pii_rejected safety counter), so those types bypass this gate. The
+ // hardCap drop-guard below still applies: it is a memory safety valve,
+ // not a feature toggle.
+ if (!config.METERING_ENABLED && !event.type.startsWith('ingestion.')) return;
if (this.buffer.length >= this.hardCap) {
this.dropped++;
return;
diff --git a/packages/backend/src/modules/metering/types.ts b/packages/backend/src/modules/metering/types.ts
index 34680053..c5a24f76 100644
--- a/packages/backend/src/modules/metering/types.ts
+++ b/packages/backend/src/modules/metering/types.ts
@@ -14,7 +14,13 @@ export type MeteringEventType =
| 'ingestion.pii_rejected'
| 'ingestion.detection_enqueue_failed'
| 'ingestion.exception_enqueue_failed'
- | 'ingestion.identifier_failed';
+ | 'ingestion.identifier_failed'
+ // Clock skew at ingestion (#279): not billed, surfaced in admin stats and per project.
+ | 'ingestion.timestamp_skew'
+ // Clock skew for spans/metrics (span-metric-skew): same rationale as above,
+ // kept under the ingestion.* prefix (see breakdown.ts / recorder.ts).
+ | 'ingestion.span_timestamp_skew'
+ | 'ingestion.metric_timestamp_skew';
export interface MeteringEvent {
type: MeteringEventType;
diff --git a/packages/backend/src/modules/metrics/service.ts b/packages/backend/src/modules/metrics/service.ts
index e75ae1f4..8986a826 100644
--- a/packages/backend/src/modules/metrics/service.ts
+++ b/packages/backend/src/modules/metrics/service.ts
@@ -1,5 +1,7 @@
import { reservoir } from '../../database/reservoir.js';
import { projectsService } from '../projects/service.js';
+import { metering } from '../metering/index.js';
+import { createSkewTracker } from '../ingestion/skew.js';
import type {
MetricRecord,
AggregationInterval,
@@ -14,14 +16,34 @@ export class MetricsService {
): Promise {
if (records.length === 0) return 0;
- const enriched = records.map((r) => ({
- ...r,
- projectId,
- organizationId,
- }));
+ // Clock skew (span-metric-skew): observed on the data point timestamp
+ // (r.time), inside the same map as the enrichment above. No PII masking
+ // exists on this path, so there is nothing to order against. 24h is a
+ // judgement call (see traces/service.ts ingestSpans for the full rationale),
+ // not a derivation: the harm here is a metric invisible in dashboard windows.
+ const metricSkewTracker = createSkewTracker(Date.now());
+ const enriched = records.map((r) => {
+ metricSkewTracker.observe(r.time);
+ return {
+ ...r,
+ projectId,
+ organizationId,
+ };
+ });
const result = await reservoir.ingestMetrics(enriched);
+ const metricSkew = metricSkewTracker.summary();
+ if (metricSkew && organizationId) {
+ metering.record({
+ type: 'ingestion.metric_timestamp_skew',
+ quantity: metricSkew.count,
+ organizationId,
+ projectId,
+ metadata: { maxPastMs: metricSkew.maxPastMs, maxFutureMs: metricSkew.maxFutureMs },
+ });
+ }
+
// Mark the project as having metrics (debounced, fire-and-forget)
projectsService.markHasData(projectId, 'metrics').catch(() => {});
diff --git a/packages/backend/src/modules/projects/routes.ts b/packages/backend/src/modules/projects/routes.ts
index a51672ce..de235571 100644
--- a/packages/backend/src/modules/projects/routes.ts
+++ b/packages/backend/src/modules/projects/routes.ts
@@ -158,6 +158,28 @@ export async function projectsRoutes(fastify: FastifyInstance) {
}
});
+ // Get project ingestion health (#279: clock skew warning surface)
+ fastify.get('/:id/ingestion-health', async (request: any, reply) => {
+ try {
+ const { id } = projectIdSchema.parse(request.params);
+
+ // Access control + org scoping: getProjectById joins organization_members
+ // on the caller, so a project outside the caller's orgs reads as 404.
+ const project = await projectsService.getProjectById(id, request.user.id);
+ if (!project) {
+ return reply.status(404).send({ error: 'Project not found' });
+ }
+
+ const health = await projectsService.getIngestionHealth(project.organizationId, id);
+ return reply.send(health);
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return reply.status(400).send({ error: 'Invalid project ID' });
+ }
+ throw error;
+ }
+ });
+
// Restore a soft-deleted project
fastify.post('/:id/restore', async (request: any, reply) => {
try {
diff --git a/packages/backend/src/modules/projects/service.ts b/packages/backend/src/modules/projects/service.ts
index d625f001..026ed426 100644
--- a/packages/backend/src/modules/projects/service.ts
+++ b/packages/backend/src/modules/projects/service.ts
@@ -1,3 +1,4 @@
+import { sql } from 'kysely';
import { db } from '../../database/connection.js';
import type { Project, StatusPageVisibility } from '@logtide/shared';
import bcrypt from 'bcrypt';
@@ -38,6 +39,30 @@ export interface UpdateProjectInput {
statusPagePassword?: string;
}
+export interface ProjectSkewHealth {
+ /** Total skewed records seen in the last 24h. */
+ count24h: number;
+ /** Worst past delta across the window, in ms. */
+ maxPastMs: number;
+ /** Worst future delta across the window, in ms. */
+ maxFutureMs: number;
+ lastSeenAt: string;
+}
+
+/** Per-signal skew health (span-metric-skew). Each entry is null when that
+ * signal had no skew in the 24h window. */
+export interface ProjectSkewHealthMap {
+ logs: ProjectSkewHealth | null;
+ spans: ProjectSkewHealth | null;
+ metrics: ProjectSkewHealth | null;
+}
+
+const SKEW_TYPE_TO_SIGNAL: Record = {
+ 'ingestion.timestamp_skew': 'logs',
+ 'ingestion.span_timestamp_skew': 'spans',
+ 'ingestion.metric_timestamp_skew': 'metrics',
+};
+
// All columns returned on every Project fetch
const PROJECT_COLUMNS = [
'id',
@@ -232,6 +257,69 @@ export class ProjectsService {
return mapProject(project);
}
+ /**
+ * Per-project ingestion health (#279, span-metric-skew). Reads the clock skew
+ * counters written by ingestLogs/ingestSpans/ingestMetrics, aggregated in SQL
+ * with one round trip per project (GROUP BY type keeps it a single query
+ * instead of three). Filters on (organization_id, type, time) plus project_id,
+ * so the planner favors idx_metering_org_type_time: type is highly selective
+ * while (org, project, 24h) alone matches every metering row for the project.
+ * metadata->>'x' yields text, so each field is cast to float8 before MAX to
+ * avoid a lexicographic (string) max. With GROUP BY, a signal with zero
+ * matching rows simply produces no row (rather than one row of NULLs), so
+ * each entry of the returned map starts null and is only filled in for types
+ * that actually matched.
+ */
+ async getIngestionHealth(
+ organizationId: string,
+ projectId: string,
+ ): Promise<{ skew: ProjectSkewHealthMap }> {
+ const since = new Date(Date.now() - 24 * 60 * 60 * 1000);
+
+ const query = sql<{
+ type: string;
+ count24h: number | string | null;
+ max_past_ms: number | string | null;
+ max_future_ms: number | string | null;
+ last_seen_at: Date | string | null;
+ }>`
+ SELECT
+ type,
+ COALESCE(SUM(quantity), 0)::float8 AS count24h,
+ COALESCE(MAX((metadata->>'maxPastMs')::float8), 0) AS max_past_ms,
+ COALESCE(MAX((metadata->>'maxFutureMs')::float8), 0) AS max_future_ms,
+ MAX(time) AS last_seen_at
+ FROM metering_events
+ WHERE organization_id = ${organizationId}
+ AND project_id = ${projectId}
+ AND type IN ('ingestion.timestamp_skew', 'ingestion.span_timestamp_skew', 'ingestion.metric_timestamp_skew')
+ AND time >= ${since}
+ GROUP BY type
+ `;
+ const result = await query.execute(db);
+
+ const toNumber = (value: number | string | null): number =>
+ typeof value === 'number' ? value : parseFloat(value ?? '0');
+
+ const skew: ProjectSkewHealthMap = { logs: null, spans: null, metrics: null };
+
+ for (const row of result.rows) {
+ const signal = SKEW_TYPE_TO_SIGNAL[row.type];
+ if (!signal) continue;
+
+ const lastSeenAt = row.last_seen_at instanceof Date ? row.last_seen_at : new Date(row.last_seen_at as string);
+
+ skew[signal] = {
+ count24h: toNumber(row.count24h),
+ maxPastMs: toNumber(row.max_past_ms),
+ maxFutureMs: toNumber(row.max_future_ms),
+ lastSeenAt: lastSeenAt.toISOString(),
+ };
+ }
+
+ return { skew };
+ }
+
/**
* Update a project (only active projects can be updated)
*/
diff --git a/packages/backend/src/modules/traces/service.ts b/packages/backend/src/modules/traces/service.ts
index 243a40e4..3cacec52 100644
--- a/packages/backend/src/modules/traces/service.ts
+++ b/packages/backend/src/modules/traces/service.ts
@@ -2,8 +2,9 @@ import { db } from '../../database/index.js';
import { pool } from '../../database/connection.js';
import { reservoir } from '../../database/reservoir.js';
import { projectsService } from '../projects/service.js';
-import { recordSpanIngestion } from '../metering/index.js';
+import { recordSpanIngestion, metering } from '../metering/index.js';
import { piiMaskingService } from '../pii-masking/service.js';
+import { createSkewTracker } from '../ingestion/skew.js';
import type { TransformedSpan, AggregatedTrace } from '../otlp/trace-transformer.js';
import type {
SpanRecord as ReservoirSpanRecord,
@@ -142,8 +143,41 @@ export class TracesService {
);
}
+ // Clock skew (span-metric-skew): observed on end_time, not start_time. A span
+ // that STARTED 27h ago is routinely legitimate (batch jobs, long transactions,
+ // long-poll), because spans are exported at end; clock skew shifts both ends
+ // together, so end_time is what separates the two cases. Counted over the
+ // post-masking survivors only, to match the log path's semantics of only
+ // counting records that actually get written (a second loop, not folded into
+ // the map above, since masking runs after it here).
+ //
+ // 24h is a judgement call here, not a derivation: for logs it is exactly the
+ // maximum alert time_window, so past it no rule can ever match. No alert rules
+ // run over spans, so there is no equivalent statement; the harm is weaker
+ // (invisible in trace view default time windows). Reused for consistency.
+ //
+ // Accepted limitation: a collector draining a multi-day persistent queue, or an
+ // offline edge agent, produces genuinely old end_time with a correct clock and
+ // will be flagged the same as real skew. Same accepted equivalence class as log
+ // backfill; we warn rather than reject because we cannot tell them apart.
+ const spanSkewTracker = createSkewTracker(Date.now());
+ for (const span of spansToStore) {
+ spanSkewTracker.observe(span.endTime);
+ }
+ const spanSkew = spanSkewTracker.summary();
+
const result = await reservoir.ingestSpans(spansToStore);
+ if (spanSkew && organizationId) {
+ metering.record({
+ type: 'ingestion.span_timestamp_skew',
+ quantity: spanSkew.count,
+ organizationId,
+ projectId,
+ metadata: { maxPastMs: spanSkew.maxPastMs, maxFutureMs: spanSkew.maxFutureMs },
+ });
+ }
+
// Metering: record ingested span count (fire-and-forget; activates
// the tracing.max_spans_monthly quota in the capability system).
if (organizationId) {
diff --git a/packages/backend/src/modules/users/routes.ts b/packages/backend/src/modules/users/routes.ts
index fb6b0c8d..a5173ae2 100644
--- a/packages/backend/src/modules/users/routes.ts
+++ b/packages/backend/src/modules/users/routes.ts
@@ -1,6 +1,10 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
+import { hub } from '@logtide/core';
+import { isInternalLoggingEnabled } from '../../utils/internal-logger.js';
+import { AuthError, AuthErrorCode } from '../auth/providers/types.js';
import { usersService } from './service.js';
+import { authenticationService } from '../auth/authentication-service.js';
import { config } from '../../config/index.js';
import { settingsService } from '../settings/service.js';
import { bootstrapService } from '../bootstrap/service.js';
@@ -52,11 +56,29 @@ export async function usersRoutes(fastify: FastifyInstance) {
const user = await usersService.createUser(body);
- // Automatically log in the new user
- const session = await usersService.login({
- email: body.email,
- password: body.password,
- });
+ // Automatically log in the new user through the provider registry.
+ // This also creates the user_identities link for the local provider.
+ // If this fails for a transient reason the user row is already committed,
+ // so we return 201 with no session instead of propagating a 500. The
+ // account is fully recoverable via a subsequent /login.
+ let session: { token: string; expiresAt: Date } | null = null;
+ try {
+ const result = await authenticationService.authenticateWithProvider('local', {
+ email: body.email,
+ password: body.password,
+ });
+ session = result.session;
+ } catch (autoLoginError) {
+ // The user row is already committed, so this stays a 201 without a
+ // session (recoverable via /login), but the cause must be visible to
+ // operators: a persistent failure here silently degrades every signup.
+ if (isInternalLoggingEnabled()) {
+ hub.captureLog('error', '[Auth] Post-registration auto-login failed', {
+ userId: user.id,
+ error: autoLoginError instanceof Error ? autoLoginError.message : String(autoLoginError),
+ });
+ }
+ }
await auditLogService.record({
action: 'user.registered',
@@ -72,10 +94,12 @@ export async function usersRoutes(fastify: FastifyInstance) {
name: user.name,
is_admin: user.is_admin,
},
- session: {
- token: session.token,
- expiresAt: session.expiresAt,
- },
+ ...(session ? {
+ session: {
+ token: session.token,
+ expiresAt: session.expiresAt,
+ },
+ } : {}),
});
} catch (error) {
if (error instanceof z.ZodError) {
@@ -111,14 +135,7 @@ export async function usersRoutes(fastify: FastifyInstance) {
try {
parsedBody = loginSchema.parse(request.body);
- const session = await usersService.login(parsedBody);
- const user = await usersService.getUserById(session.userId);
-
- if (!user) {
- return reply.status(500).send({
- error: 'Internal server error',
- });
- }
+ const { user, session } = await authenticationService.authenticateWithProvider('local', parsedBody);
await auditLogService.record({
action: 'auth.login_succeeded',
@@ -147,17 +164,30 @@ export async function usersRoutes(fastify: FastifyInstance) {
});
}
- if (error instanceof Error) {
- if (error.message.includes('Invalid')) {
- // parsedBody is defined here (zod succeeded before the service threw)
+ if (error instanceof AuthError) {
+ // Classify by the provider's typed code, not the message string, so a
+ // reword of a user-facing message can never silently turn a login
+ // failure into a 500 or drop its audit row.
+ const mapping: Partial> = {
+ [AuthErrorCode.INVALID_CREDENTIALS]: { status: 401, reason: 'invalid_credentials' },
+ [AuthErrorCode.SSO_REQUIRED]: { status: 401, reason: 'sso_required' },
+ [AuthErrorCode.USER_DISABLED]: { status: 401, reason: 'account_disabled' },
+ [AuthErrorCode.PROVIDER_UNAVAILABLE]: { status: 503, reason: 'provider_unavailable' },
+ };
+ const mapped = mapping[error.code];
+
+ if (mapped) {
+ // parsedBody is defined here (zod succeeded before the service threw).
+ // The reason lands only in the audit log, never in the HTTP response,
+ // so recording it does not weaken the anti-enumeration behavior.
await auditLogService.record({
action: 'auth.login_failed',
outcome: 'failure',
organizationId: null,
actor: { type: 'user', id: null, label: parsedBody?.email ?? null },
- metadata: { method: 'local' },
+ metadata: { method: 'local', reason: mapped.reason },
});
- return reply.status(401).send({
+ return reply.status(mapped.status).send({
error: error.message,
});
}
diff --git a/packages/backend/src/modules/users/service.ts b/packages/backend/src/modules/users/service.ts
index 3dc1f36b..2c5819f1 100644
--- a/packages/backend/src/modules/users/service.ts
+++ b/packages/backend/src/modules/users/service.ts
@@ -5,7 +5,6 @@ import { db } from '../../database/connection.js';
import { CacheManager, CACHE_TTL } from '../../utils/cache.js';
const SALT_ROUNDS = 10;
-const SESSION_DURATION_DAYS = 30;
// Constant key for the Postgres transaction-scoped advisory lock that
// serializes the first-admin promotion decision across concurrent
@@ -18,11 +17,6 @@ export interface CreateUserInput {
name: string;
}
-export interface LoginInput {
- email: string;
- password: string;
-}
-
export interface SessionInfo {
sessionId: string;
userId: string;
@@ -149,67 +143,6 @@ export class UsersService {
};
}
- /**
- * Authenticate a user and create a session
- */
- async login(input: LoginInput): Promise {
- // Find user by email (case-insensitive, matching how emails are stored)
- const user = await db
- .selectFrom('users')
- .select(['id', 'email', 'password_hash', 'disabled'])
- .where('email', '=', input.email.toLowerCase().trim())
- .executeTakeFirst();
-
- if (!user) {
- throw new Error('Invalid email or password');
- }
-
- // Check if user has a local password (external auth users may not)
- if (!user.password_hash) {
- throw new Error('Please log in using your organization SSO');
- }
-
- // Verify password
- const isValidPassword = await this.verifyPassword(input.password, user.password_hash);
- if (!isValidPassword) {
- throw new Error('Invalid email or password');
- }
-
- // Check if account is disabled
- if (user.disabled) {
- throw new Error('This account has been disabled');
- }
-
- // Update last login
- await db
- .updateTable('users')
- .set({ last_login: new Date() })
- .where('id', '=', user.id)
- .execute();
-
- // Create session
- const token = this.generateToken();
- const expiresAt = new Date();
- expiresAt.setDate(expiresAt.getDate() + SESSION_DURATION_DAYS);
-
- const session = await db
- .insertInto('sessions')
- .values({
- user_id: user.id,
- token,
- expires_at: expiresAt,
- })
- .returning(['id', 'token', 'expires_at'])
- .executeTakeFirstOrThrow();
-
- return {
- sessionId: session.id,
- userId: user.id,
- token: session.token,
- expiresAt: new Date(session.expires_at),
- };
- }
-
/**
* Validate a session token and return user info
* Cached for performance - session validation happens on every request
diff --git a/packages/backend/src/queue/jobs/digest-dispatch.ts b/packages/backend/src/queue/jobs/digest-dispatch.ts
new file mode 100644
index 00000000..3595b115
--- /dev/null
+++ b/packages/backend/src/queue/jobs/digest-dispatch.ts
@@ -0,0 +1,112 @@
+/**
+ * Digest Dispatch Job Processor
+ *
+ * Runs hourly (single static cron registered at worker boot). Scans the active
+ * digest configs, decides which ones are due at the current UTC hour and
+ * enqueues one digest-generation job per due config.
+ *
+ * This indirection exists because graphile-worker cron items are fixed at
+ * runner start: per-org cron jobs cannot be added or removed while the worker
+ * is running, so config CRUD would need a worker restart to take effect. A
+ * single static dispatch cron plus a due-check keeps schedule changes live on
+ * both queue backends.
+ */
+
+import { db } from '../../database/connection.js';
+import { createQueue } from '../connection.js';
+import { hub } from '@logtide/core';
+import type { IJob } from '../abstractions/types.js';
+import type { DigestJobPayload } from '../../modules/digests/scheduler.js';
+
+interface DispatchableConfig {
+ frequency: 'daily' | 'weekly';
+ delivery_hour: number;
+ delivery_day_of_week: number | null;
+ last_sent_at: Date | null;
+}
+
+// A digest fires at most once per delivery slot; anything sent less than two
+// hours ago is a duplicate trigger (worker restart, overlapping cron tick).
+const DOUBLE_FIRE_GUARD_MS = 2 * 60 * 60 * 1000;
+
+export function isConfigDue(config: DispatchableConfig, now: Date): boolean {
+ if (now.getUTCHours() !== config.delivery_hour) {
+ return false;
+ }
+
+ if (config.frequency === 'weekly' && now.getUTCDay() !== config.delivery_day_of_week) {
+ return false;
+ }
+
+ if (
+ config.last_sent_at &&
+ now.getTime() - new Date(config.last_sent_at).getTime() < DOUBLE_FIRE_GUARD_MS
+ ) {
+ return false;
+ }
+
+ return true;
+}
+
+export async function processDigestDispatch(_job: IJob>): Promise {
+ const now = new Date();
+
+ // tenant-scope-ok: system dispatch cron sweeps every org's digest config by design
+ const configs = await db
+ .selectFrom('digest_configs')
+ .select([
+ 'id',
+ 'organization_id',
+ 'frequency',
+ 'delivery_hour',
+ 'delivery_day_of_week',
+ 'last_sent_at',
+ ])
+ .where('enabled', '=', true)
+ .execute();
+
+ const due = configs.filter((config) =>
+ isConfigDue(
+ {
+ frequency: config.frequency,
+ delivery_hour: config.delivery_hour,
+ delivery_day_of_week: config.delivery_day_of_week,
+ // Already a Date (Kysely Timestamp select type); no string round-trip.
+ last_sent_at: config.last_sent_at ?? null,
+ },
+ now
+ )
+ );
+
+ if (due.length === 0) {
+ return;
+ }
+
+ hub.captureLog('info', `[DigestDispatch] ${due.length} digest(s) due at ${now.toISOString()}`);
+
+ const queue = createQueue('digest-generation');
+ const hourKey = now.toISOString().slice(0, 13); // YYYY-MM-DDTHH (UTC)
+
+ for (const config of due) {
+ await queue.add(
+ 'digest-generation',
+ {
+ organizationId: config.organization_id,
+ digestConfigId: config.id,
+ frequency: config.frequency,
+ },
+ {
+ // Hour-keyed so a duplicate dispatch within the same slot dedupes
+ jobKey: `digest:${config.id}:${hourKey}`,
+ removeOnComplete: true,
+ }
+ );
+
+ // tenant-scope-ok: id comes from the enabled-configs sweep above; system cron has no org context
+ await db
+ .updateTable('digest_configs')
+ .set({ last_sent_at: now })
+ .where('id', '=', config.id)
+ .execute();
+ }
+}
diff --git a/packages/backend/src/queue/jobs/error-notification.ts b/packages/backend/src/queue/jobs/error-notification.ts
index abd10dd3..12336fd1 100644
--- a/packages/backend/src/queue/jobs/error-notification.ts
+++ b/packages/backend/src/queue/jobs/error-notification.ts
@@ -28,6 +28,8 @@ export interface ErrorNotificationJobData {
language: ExceptionLanguage;
service: string;
isNewErrorGroup: boolean;
+ /** True when a previously resolved error group recurred (a regression). */
+ isRegression?: boolean;
}
// Create the queue
@@ -72,9 +74,9 @@ async function sendErrorWebhook(
organizationId: data.organizationId,
projectId: data.projectId ?? null,
data: {
- title: `${data.isNewErrorGroup ? 'New Error' : 'Error'}: ${data.exceptionType}`,
+ title: `${data.isRegression ? 'Regression' : data.isNewErrorGroup ? 'New Error' : 'Error'}: ${data.exceptionType}`,
message: data.exceptionMessage || `An error occurred in ${data.service}`,
- severity: data.isNewErrorGroup ? 'high' : 'medium',
+ severity: data.isRegression || data.isNewErrorGroup ? 'high' : 'medium',
organization: {
id: data.organizationId,
name: orgName,
@@ -88,6 +90,7 @@ async function sendErrorWebhook(
language: data.language,
service: data.service,
is_new: data.isNewErrorGroup,
+ is_regression: data.isRegression ?? false,
link: `${frontendUrl}/dashboard/errors/${errorGroupId}`,
},
});
@@ -220,7 +223,9 @@ export async function processErrorNotification(job: IJob
}
const fingerprint = FingerprintService.generate(parsed);
+ const topFrame = FingerprintService.topAppFrame(parsed);
// Check if this is a new error group (first occurrence with this fingerprint)
- const existingGroup = await db
+ let existingGroup = await db
.selectFrom('error_groups')
.select(['id', 'occurrence_count', 'status'])
.where('fingerprint', '=', fingerprint)
.where('organization_id', '=', organizationId)
.executeTakeFirst();
+ // No exact fingerprint match: auto-merge into an existing group that shares
+ // the coarse key (same type + normalized message + top app frame), so a
+ // stack that differs only in its deep frames does not spawn a duplicate
+ // group. The key is computed by the same Postgres function on both sides.
+ let effectiveFingerprint = fingerprint;
+ if (!existingGroup && topFrame) {
+ const mergeMatch = await db
+ .selectFrom('error_groups')
+ .select(['id', 'fingerprint', 'occurrence_count', 'status'])
+ .where('organization_id', '=', organizationId)
+ .where('project_id', '=', projectId)
+ .where(
+ 'merge_key',
+ '=',
+ sql`logtide_merge_key(${parsed.exceptionType}, ${parsed.exceptionMessage}, ${topFrame})`
+ )
+ .orderBy('first_seen', 'asc')
+ .executeTakeFirst();
+
+ if (mergeMatch) {
+ effectiveFingerprint = mergeMatch.fingerprint;
+ existingGroup = {
+ id: mergeMatch.id,
+ occurrence_count: mergeMatch.occurrence_count,
+ status: mergeMatch.status,
+ };
+ }
+ }
+
const isNewErrorGroup = !existingGroup;
+ // A previously resolved error that recurs is a regression.
+ const isRegression = existingGroup?.status === 'resolved';
const exceptionId = await exceptionService.createException({
organizationId,
projectId,
logId: log.id,
parsedData: parsed,
- fingerprint,
+ fingerprint: effectiveFingerprint,
+ service: log.service,
+ topFrame,
});
+ // Reopen a resolved group so the regression is visible and not silently
+ // folded into a "resolved" bucket.
+ if (isRegression && existingGroup) {
+ await db
+ .updateTable('error_groups')
+ .set({ status: 'open', resolved_at: null, resolved_by: null, updated_at: new Date() })
+ .where('id', '=', existingGroup.id)
+ .where('organization_id', '=', organizationId)
+ .execute();
+ }
+
stats.parsed++;
console.log(
@@ -104,12 +150,13 @@ export async function processExceptionParsing(job: IJob
exceptionId,
organizationId,
projectId,
- fingerprint,
+ fingerprint: effectiveFingerprint,
exceptionType: parsed.exceptionType,
exceptionMessage: parsed.exceptionMessage,
language: parsed.language,
service: log.service,
isNewErrorGroup,
+ isRegression,
};
await errorNotificationQueue.add('error-notification', notificationData, {
diff --git a/packages/backend/src/server.ts b/packages/backend/src/server.ts
index 1733622a..ccc9f346 100644
--- a/packages/backend/src/server.ts
+++ b/packages/backend/src/server.ts
@@ -50,6 +50,8 @@ import { auditLogRoutes, auditLogService } from './modules/audit-log/index.js';
import { bootstrapService } from './modules/bootstrap/index.js';
import { runDataAvailabilityBackfill } from './modules/projects/data-availability-backfill.js';
import { notificationChannelsRoutes } from './modules/notification-channels/index.js';
+import { digestsRoutes } from './modules/digests/routes.js';
+import { digestsPublicRoutes } from './modules/digests/public-routes.js';
import { webhookDeliveriesRoutes } from './modules/webhooks/routes.js';
import internalLoggingPlugin from './plugins/internal-logging-plugin.js';
import { initializeInternalLogging, shutdownInternalLogging } from './utils/internal-logger.js';
@@ -182,6 +184,9 @@ export async function build(opts = {}) {
await fastify.register(projectsRoutes, { prefix: '/api/v1/projects' });
await fastify.register(notificationsRoutes, { prefix: '/api/v1/notifications' });
await fastify.register(notificationChannelsRoutes, { prefix: '/api/v1/notification-channels' });
+ await fastify.register(digestsRoutes, { prefix: '/api/v1/digests' });
+ // Public one-click unsubscribe: the URL token is the credential, no auth (#154)
+ await fastify.register(digestsPublicRoutes, { prefix: '/api/v1/digests' });
await fastify.register(webhookDeliveriesRoutes, { prefix: '/api/v1/webhooks' });
await fastify.register(onboardingRoutes, { prefix: '/api/v1/onboarding' });
await fastify.register(alertsRoutes, { prefix: '/api/v1/alerts' });
diff --git a/packages/backend/src/tests/helpers/factories.ts b/packages/backend/src/tests/helpers/factories.ts
index 55032d20..954fb890 100644
--- a/packages/backend/src/tests/helpers/factories.ts
+++ b/packages/backend/src/tests/helpers/factories.ts
@@ -7,14 +7,16 @@ import crypto from 'crypto';
*/
export async function createTestUser(overrides: {
email?: string;
- password?: string;
+ /** null creates an SSO-only user with no local password */
+ password?: string | null;
name?: string;
+ disabled?: boolean;
} = {}) {
const email = overrides.email || `test-${Date.now()}@example.com`;
- const password = overrides.password || 'password123';
+ const password = overrides.password === undefined ? 'password123' : overrides.password;
const name = overrides.name || 'Test User';
- const hashedPassword = await bcrypt.hash(password, 10);
+ const hashedPassword = password === null ? null : await bcrypt.hash(password, 10);
const user = await db
.insertInto('users')
@@ -22,6 +24,7 @@ export async function createTestUser(overrides: {
email,
password_hash: hashedPassword,
name,
+ disabled: overrides.disabled ?? false,
})
.returningAll()
.executeTakeFirstOrThrow();
diff --git a/packages/backend/src/tests/lib/email-templates.test.ts b/packages/backend/src/tests/lib/email-templates.test.ts
index 0cb9692e..ff9b25df 100644
--- a/packages/backend/src/tests/lib/email-templates.test.ts
+++ b/packages/backend/src/tests/lib/email-templates.test.ts
@@ -18,6 +18,8 @@ import {
generateIncidentEmail,
generateSigmaDetectionEmail,
generateInvitationEmail,
+ generateDigestEmail,
+ type DigestEmailData,
} from '../../lib/email-templates.js';
describe('Email Templates - Helpers', () => {
@@ -752,3 +754,120 @@ describe('Email Templates - Common Elements', () => {
expect(result.html).toContain('