From 7ff379512ee8f480bccae21b9a706c4a89f62d8f Mon Sep 17 00:00:00 2001 From: Jonathan Brennan Date: Mon, 30 Mar 2026 14:44:59 -0500 Subject: [PATCH 01/18] updated plan --- .../alerts-and-notifications-improvements.md | 590 ++++++++++++++++++ 1 file changed, 590 insertions(+) create mode 100644 packages/web/app/.claude/plans/alerts-and-notifications-improvements.md diff --git a/packages/web/app/.claude/plans/alerts-and-notifications-improvements.md b/packages/web/app/.claude/plans/alerts-and-notifications-improvements.md new file mode 100644 index 0000000000..b35e397fb7 --- /dev/null +++ b/packages/web/app/.claude/plans/alerts-and-notifications-improvements.md @@ -0,0 +1,590 @@ +# Alerts & Notifications Improvement Proposal + +## Motivation + +The existing alerts system only supports schema change notifications via Slack, Webhook, and MS Teams. Users have no way to be alerted when their GraphQL API's latency spikes, error rate degrades, or traffic volume changes unexpectedly. These are the most valuable alert types from a production monitoring perspective. + +We also lack email as a notification channel, which is table stakes for an alerting system. + +This proposal covers two workstreams: + +1. **Email alert channel** — Add EMAIL as a new notification channel type, benefiting both existing schema-change alerts and the new metric alerts +2. **Metric-based alerts** — Add configurable alerts for latency, error rate, and traffic with periodic evaluation against ClickHouse data + +### Design decisions made so far + +- **Resolved notifications**: Send a "resolved" notification when a metric alert transitions from FIRING back to OK +- **Alert scoping**: Metric alerts can optionally be scoped to a specific insights filter (operation IDs and/or client name+version combinations) +- **Multiple email recipients**: Email channels support an array of addresses +- **Severity levels**: Alerts carry a user-defined severity label (info, warning, critical) for organizational purposes + +--- + +## Phase 1: Email Alert Channel + +Follows the exact same pattern used when MS Teams was added (`2024.06.11T10-10-00.ms-teams-webhook.ts`). + +### 1.1 Database Migration + +**New file:** `packages/migrations/src/actions/2026.03.27T00-00-00.email-alert-channel.ts` + +```sql +ALTER TYPE alert_channel_type ADD VALUE 'EMAIL'; +-- Array of email addresses to support multiple recipients per channel +ALTER TABLE alert_channels ADD COLUMN email_addresses TEXT[]; +``` + +Register in `packages/migrations/src/run-pg-migrations.ts`. + +### 1.2 Data Access + +The legacy storage service (`packages/services/storage/src/index.ts`) will not be extended. Instead, following the modern pattern used by recent modules (app-deployments, schema-proposals, saved-filters), we create a module-level provider that injects `PG_POOL_CONFIG` directly. + +**New file:** `packages/services/api/src/modules/alerts/providers/alert-channels-storage.ts` + +```typescript +@Injectable({ scope: Scope.Operation }) +export class AlertChannelsStorage { + constructor(@Inject(PG_POOL_CONFIG) private pool: DatabasePool) {} + + async addAlertChannel(input: { ... emailAddresses?: string[] | null }) { ... } + async getAlertChannels(projectId: string) { ... } + async deleteAlertChannels(projectId: string, channelIds: string[]) { ... } +} +``` + +This provider takes over alert channel CRUD from the legacy storage module. Existing callers in `AlertsManager` are updated to use this new provider instead. + +**File:** `packages/services/api/src/shared/entities.ts` — add `emailAddresses: string[] | null` to `AlertChannel` interface. + +### 1.3 GraphQL API + +**File:** `packages/services/api/src/modules/alerts/module.graphql.ts` + +```graphql +# Add EMAIL to existing enum +enum AlertChannelType { SLACK, WEBHOOK, MSTEAMS_WEBHOOK, EMAIL } + +# New implementing type +type AlertEmailChannel implements AlertChannel { + id: ID! + name: String! + type: AlertChannelType! + emails: [String!]! +} + +# New input — accepts multiple recipients +input EmailChannelInput { + emails: [String!]! +} + +# Update AddAlertChannelInput to include email field +input AddAlertChannelInput { + ...existing fields... + email: EmailChannelInput +} +``` + +### 1.4 Resolvers + +**New file:** `packages/services/api/src/modules/alerts/resolvers/AlertEmailChannel.ts` +```typescript +export const AlertEmailChannel: AlertEmailChannelResolvers = { + __isTypeOf: channel => channel.type === 'EMAIL', + emails: channel => channel.emailAddresses ?? [], +}; +``` + +**File:** `packages/services/api/src/modules/alerts/resolvers/Mutation/addAlertChannel.ts` +- Add Zod validation: `email: MaybeModel(z.object({ emails: z.array(z.string().email().max(255)).min(1).max(10) }))` +- Pass `emailAddresses: input.email?.emails` to AlertsManager + +### 1.5 AlertsManager + +**File:** `packages/services/api/src/modules/alerts/providers/alerts-manager.ts` +- Update `addChannel()` input to accept `emailAddresses?: string[] | null` +- Pass through to storage +- Update `triggerChannelConfirmation()` to handle EMAIL type +- Update `triggerSchemaChangeNotifications()` to dispatch via email adapter + +### 1.6 Email Communication Adapter + +**New file:** `packages/services/api/src/modules/alerts/providers/adapters/email.ts` + +Implements `CommunicationAdapter` interface. Uses `TaskScheduler` to schedule an email task in the workflows service (same pattern as `WebhookCommunicationAdapter` which schedules `SchemaChangeNotificationTask`). + +```typescript +@Injectable() +export class EmailCommunicationAdapter implements CommunicationAdapter { + constructor(private taskScheduler: TaskScheduler, private logger: Logger) {} + + async sendSchemaChangeNotification(input: SchemaChangeNotificationInput) { + await this.taskScheduler.scheduleTask(AlertEmailNotificationTask, { + recipients: input.channel.emailAddresses ?? [], + event: { /* schema change details */ }, + }); + } + + async sendChannelConfirmation(input: ChannelConfirmationInput) { + await this.taskScheduler.scheduleTask(AlertEmailConfirmationTask, { + recipients: input.channel.emailAddresses ?? [], + event: input.event, + }); + } +} +``` + +### 1.7 Email Task + Template (Workflows Service) + +**New file:** `packages/services/workflows/src/tasks/alert-email-notification.ts` +- Define `AlertEmailNotificationTask` and `AlertEmailConfirmationTask` +- Send emails using `context.email.send()` with MJML templates (same pattern as `email-verification.ts`) + +**New file:** `packages/services/workflows/src/lib/emails/templates/alert-notification.ts` +- MJML email template for schema change notifications (use existing `email()`, `paragraph()`, `button()` helpers from `components.ts`) + +**File:** `packages/services/workflows/src/index.ts` — register new task module + +### 1.8 Module Registration + +**File:** `packages/services/api/src/modules/alerts/index.ts` — add `EmailCommunicationAdapter` to providers + +### 1.9 Frontend + +**File:** `packages/web/app/src/components/project/alerts/create-channel.tsx` +- Add email addresses field with Zod validation (the existing form uses Yup + Formik, but new form code should use Zod + react-hook-form to match the current codebase convention) +- Show email input when type === EMAIL +- Pass `email: { emails: values.emailAddresses }` in mutation + +**File:** `packages/web/app/src/components/project/alerts/channels-table.tsx` +- Handle `AlertEmailChannel` typename to display the email addresses + +### Key files for Phase 1 + +| File | Change | +|------|--------| +| `packages/migrations/src/actions/2026.03.27T00-00-00.email-alert-channel.ts` | **New** — migration | +| `packages/migrations/src/run-pg-migrations.ts` | Register migration | +| `packages/services/api/src/modules/alerts/providers/alert-channels-storage.ts` | **New** — module-level CRUD provider (replaces legacy storage) | +| `packages/services/api/src/shared/entities.ts` | Add emailAddresses to AlertChannel | +| `packages/services/api/src/modules/alerts/module.graphql.ts` | Add AlertEmailChannel type + EmailChannelInput | +| `packages/services/api/src/modules/alerts/resolvers/AlertEmailChannel.ts` | **New** — resolver | +| `packages/services/api/src/modules/alerts/resolvers/Mutation/addAlertChannel.ts` | Add email validation + input | +| `packages/services/api/src/modules/alerts/providers/alerts-manager.ts` | Handle EMAIL in dispatch | +| `packages/services/api/src/modules/alerts/providers/adapters/email.ts` | **New** — adapter | +| `packages/services/api/src/modules/alerts/index.ts` | Register adapter | +| `packages/services/workflows/src/tasks/alert-email-notification.ts` | **New** — email task | +| `packages/services/workflows/src/lib/emails/templates/alert-notification.ts` | **New** — MJML template | +| `packages/services/workflows/src/index.ts` | Register task | +| `packages/web/app/src/components/project/alerts/create-channel.tsx` | Add email form field | +| `packages/web/app/src/components/project/alerts/channels-table.tsx` | Display email channels | + +--- + +## Phase 2: Metric-Based Alerts + +### Why a new table? + +The existing `alerts` table is tightly coupled to schema-change notifications — it has a fixed `alert_type` enum with only `SCHEMA_CHANGE_NOTIFICATIONS`, and no configuration columns. Metric alerts need substantially different configuration: time windows, metric selectors, threshold types/values, comparison direction, severity, evaluation state, and optional operation/client filters. + +A new `metric_alerts` table keeps both systems clean and independently evolvable. It reuses the existing `alert_channels` table (including the new EMAIL type from Phase 1) for notification delivery. + +### 2.1 Database Migration + +**New file:** `packages/migrations/src/actions/2026.03.27T00-00-01.metric-alerts.ts` + +```sql +CREATE TYPE metric_alert_type AS ENUM ('LATENCY', 'ERROR_RATE', 'TRAFFIC'); +CREATE TYPE metric_alert_metric AS ENUM ('avg', 'p75', 'p90', 'p95', 'p99'); +CREATE TYPE metric_alert_threshold_type AS ENUM ('FIXED_VALUE', 'PERCENTAGE_CHANGE'); +CREATE TYPE metric_alert_direction AS ENUM ('ABOVE', 'BELOW'); +CREATE TYPE metric_alert_severity AS ENUM ('INFO', 'WARNING', 'CRITICAL'); + +-- Alert configuration (what to monitor and how) +CREATE TABLE metric_alerts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + target_id UUID NOT NULL REFERENCES targets(id) ON DELETE CASCADE, + alert_channel_id UUID NOT NULL REFERENCES alert_channels(id) ON DELETE CASCADE, + type metric_alert_type NOT NULL, + time_window_minutes INT NOT NULL DEFAULT 30, + metric metric_alert_metric, -- only for LATENCY type + threshold_type metric_alert_threshold_type NOT NULL, + threshold_value DOUBLE PRECISION NOT NULL, + direction metric_alert_direction NOT NULL DEFAULT 'ABOVE', + severity metric_alert_severity NOT NULL DEFAULT 'WARNING', + name TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + enabled BOOLEAN NOT NULL DEFAULT true, + last_evaluated_at TIMESTAMPTZ, + -- Optional insights filter scoping (stored as JSONB) + -- Matches OperationStatsFilterInput: { operationIds?, excludeOperations?, + -- clientVersionFilters?: [{clientName, versions?}], excludeClientVersionFilters? } + filter JSONB, + CONSTRAINT metric_alerts_metric_required CHECK ( + (type = 'LATENCY' AND metric IS NOT NULL) OR (type != 'LATENCY' AND metric IS NULL) + ) +); + +CREATE INDEX idx_metric_alerts_enabled ON metric_alerts(enabled) WHERE enabled = true; + +-- Alert incident history (each time an alert fires and resolves) +CREATE TABLE metric_alert_incidents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + metric_alert_id UUID NOT NULL REFERENCES metric_alerts(id) ON DELETE CASCADE, + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + resolved_at TIMESTAMPTZ, -- NULL while still firing + current_value DOUBLE PRECISION NOT NULL, + previous_value DOUBLE PRECISION, + threshold_value DOUBLE PRECISION NOT NULL -- snapshot of threshold at time of incident +); + +CREATE INDEX idx_metric_alert_incidents_alert ON metric_alert_incidents(metric_alert_id); +CREATE INDEX idx_metric_alert_incidents_open ON metric_alert_incidents(metric_alert_id) + WHERE resolved_at IS NULL; +``` + +### 2.2 Data Access + +**New file:** `packages/services/api/src/modules/alerts/providers/metric-alerts-storage.ts` + +Module-level provider with direct `PG_POOL_CONFIG` injection (same modern pattern as Phase 1). Provides: + +Alert configuration CRUD: +- `addMetricAlert(input)`, `updateMetricAlert(id, fields)`, `deleteMetricAlerts(ids)` +- `getMetricAlerts(projectId)`, `getMetricAlertsByTarget(targetId)` +- `getAllEnabledMetricAlerts()` — used by the workflows evaluation task + +Incident management: +- `createIncident(alertId, currentValue, previousValue, thresholdValue)` — called when alert transitions OK → FIRING +- `resolveIncident(alertId)` — sets `resolved_at` on the open incident when alert transitions FIRING → OK +- `getOpenIncident(alertId)` — find currently firing incident (where `resolved_at IS NULL`) +- `getIncidentHistory(alertId, limit, offset)` — paginated history for the UI + +### 2.3 GraphQL API + +**File:** `packages/services/api/src/modules/alerts/module.graphql.ts` + +```graphql +enum MetricAlertType { LATENCY, ERROR_RATE, TRAFFIC } +enum MetricAlertMetric { avg, p75, p90, p95, p99 } +enum MetricAlertThresholdType { FIXED_VALUE, PERCENTAGE_CHANGE } +enum MetricAlertDirection { ABOVE, BELOW } +enum MetricAlertSeverity { INFO, WARNING, CRITICAL } + +type MetricAlert { + id: ID! + name: String! + type: MetricAlertType! + target: Target! + channel: AlertChannel! + timeWindowMinutes: Int! + metric: MetricAlertMetric + thresholdType: MetricAlertThresholdType! + thresholdValue: Float! + direction: MetricAlertDirection! + severity: MetricAlertSeverity! + enabled: Boolean! + lastEvaluatedAt: DateTime + createdAt: DateTime! + filter: OperationStatsFilterInput + """Whether this alert is currently firing (has an open incident)""" + isFiring: Boolean! + """The currently open incident, if any""" + currentIncident: MetricAlertIncident + """Past incidents for this alert""" + incidents(first: Int, after: String): MetricAlertIncidentConnection! +} + +type MetricAlertIncident { + id: ID! + startedAt: DateTime! + resolvedAt: DateTime + currentValue: Float! + previousValue: Float + thresholdValue: Float! +} + +extend type Project { metricAlerts: [MetricAlert!] } + +# Mutations: addMetricAlert, updateMetricAlert, deleteMetricAlerts +# (standard ok/error result pattern) +``` + +**New resolver files:** `MetricAlert.ts`, `Mutation/addMetricAlert.ts`, `Mutation/updateMetricAlert.ts`, `Mutation/deleteMetricAlerts.ts` + +Uses `alert:modify` permission. + +### 2.4 Evaluation Engine (Workflows Service) + +#### Why the workflows service? + +The workflows service (`packages/services/workflows/`) is our existing background job runner, built on Graphile-Worker with PostgreSQL-backed task queues. It already runs periodic cron jobs (cleanup tasks, etc.) and one-off tasks scheduled by the API (emails, webhooks). Metric alert evaluation — a periodic job that queries data and sends notifications — is exactly what this service was built for. + +#### Add ClickHouse to Workflows + +The workflows service currently only has PostgreSQL access. We need to add a lightweight ClickHouse HTTP client so it can query operations metrics. + +- `packages/services/workflows/src/environment.ts` — add `CLICKHOUSE_HOST`, `CLICKHOUSE_PORT`, `CLICKHOUSE_USERNAME`, `CLICKHOUSE_PASSWORD` +- `packages/services/workflows/src/context.ts` — add `clickhouse` to Context +- `packages/services/workflows/src/lib/clickhouse-client.ts` — **new**, simple fetch-based HTTP client (the API's ClickHouse client is DI-heavy; we just need raw query execution) +- `packages/services/workflows/src/index.ts` — instantiate and inject + +#### Evaluation Task + +**New file:** `packages/services/workflows/src/tasks/evaluate-metric-alerts.ts` + +Cron: `* * * * * evaluateMetricAlerts` (every minute) + +The ingestion pipeline (API buffer → Kafka → ClickHouse async insert) has a worst-case latency of ~31 seconds, so by the time each 1-minute cron tick fires, the previous minute's data is reliably available. This gives on-call engineers a worst-case ~2 minute detection time. + +1. Fetch all enabled metric alerts from PostgreSQL (join with `alert_channels`, `targets`, `projects`, `organizations`) +2. Group by `(target_id, time_window_minutes, filter)` to batch ClickHouse queries +3. Query ClickHouse for current window and previous window (with 1-minute offset to account for ingestion pipeline latency) +4. Compare metric values against thresholds +5. State transitions (determined by whether an open incident exists for the alert): + - **OK → FIRING**: create a new incident row, send notification, update `last_evaluated_at` + - **FIRING → FIRING**: no notification (prevent spam), update `last_evaluated_at` + - **FIRING → OK**: set `resolved_at` on the open incident, send "resolved" notification, update `last_evaluated_at` + - **OK → OK**: update `last_evaluated_at` only + +#### ClickHouse Query Design + +**Key optimization**: Fetch both windows and all metrics in a **single query** per `(target, filter)` group. This serves latency, error rate, and traffic alerts simultaneously and halves round-trips by returning both the current and previous window in one result. + +```sql +SELECT + toStartOfInterval(timestamp, INTERVAL {windowMinutes} MINUTE) as window, + sum(total) as total, + sum(total_ok) as total_ok, + avgMerge(duration_avg) as average, + quantilesMerge(0.75, 0.90, 0.95, 0.99)(duration_quantiles) as percentiles +FROM operations_minutely +WHERE target = {targetId} + AND timestamp >= {previousWindowStart} AND timestamp < {currentWindowEnd} + [AND hash IN/NOT IN ({operationIds})] + [AND (client_name, client_version) IN/NOT IN ({clientFilters})] +GROUP BY window +ORDER BY window +``` + +Returns 2 rows (previous and current window). From these: +- **Latency**: pick the relevant percentile or average from each row +- **Error rate**: `(total - total_ok) / total * 100` per row +- **Traffic**: `total` per row + +#### Threshold Comparison + +- **FIXED_VALUE**: `currentValue > thresholdValue` (or `<` for BELOW direction) +- **PERCENTAGE_CHANGE**: `((currentValue - previousValue) / previousValue) * 100 > thresholdValue` + +Edge cases: skip if both windows have 0 data; treat as absolute if previous is 0 with relative threshold. + +### 2.5 Notifications from Workflows + +**New file:** `packages/services/workflows/src/lib/metric-alert-notifier.ts` + +Sends notifications directly from the workflows service (the API's DI container is not available here): +- **Webhooks**: Use existing `RequestBroker` / `send-webhook.ts` already in the workflows service +- **Slack**: Direct HTTP POST using `@slack/web-api`. The bot token is stored on the `organizations` table as `slack_token` and can be queried via PostgreSQL, which the workflows service already has access to. +- **Teams**: Direct HTTP POST to the webhook URL stored on the channel (simple fetch, same pattern as the API's `TeamsCommunicationAdapter`) +- **Email**: Use `context.email.send()` (from Phase 1 infrastructure) + +Example messages: + +Firing (Slack): +> :rotating_light: **Latency Alert: "API p99 Spike"** — Target: `my-target` in `my-project` +> p99 latency is **450ms** (was 200ms, +125%) — Threshold: above 200ms + +Resolved (Slack): +> :white_check_mark: **Resolved: "API p99 Spike"** — p99 latency is now **180ms** (threshold: 200ms) + +Webhook payload: +```json +{ + "type": "metric_alert", + "state": "firing", + "alert": { "name": "...", "type": "LATENCY", "metric": "p99", "severity": "warning" }, + "currentValue": 450, "previousValue": 200, "changePercent": 125, + "threshold": { "type": "FIXED_VALUE", "value": 200, "direction": "ABOVE" }, + "filter": { "operationIds": ["abc123"] }, + "target": { "slug": "..." }, "project": { "slug": "..." }, "organization": { "slug": "..." } +} +``` + +### 2.6 Deployment + +**File:** `deployment/services/workflows.ts` — add ClickHouse env vars to the workflows service deployment config. + +### Key files for Phase 2 + +| File | Change | +|------|--------| +| `packages/migrations/src/actions/2026.03.27T00-00-01.metric-alerts.ts` | **New** — migration | +| `packages/services/api/src/modules/alerts/providers/metric-alerts-storage.ts` | **New** — module-level CRUD provider | +| `packages/services/api/src/modules/alerts/module.graphql.ts` | Add MetricAlert types/mutations | +| `packages/services/api/src/modules/alerts/resolvers/` | New resolver files | +| `packages/services/api/src/modules/alerts/providers/alerts-manager.ts` | Add metric alert methods | +| `packages/services/workflows/src/environment.ts` | Add ClickHouse env vars | +| `packages/services/workflows/src/context.ts` | Add clickhouse to Context | +| `packages/services/workflows/src/lib/clickhouse-client.ts` | **New** — lightweight CH client | +| `packages/services/workflows/src/tasks/evaluate-metric-alerts.ts` | **New** — evaluation cron task | +| `packages/services/workflows/src/lib/metric-alert-notifier.ts` | **New** — notification sender | +| `packages/services/workflows/src/index.ts` | Register task + crontab | +| `deployment/services/workflows.ts` | ClickHouse env vars | + +--- + +## Open Question: Time Window Sizes and ClickHouse Data Retention + +The UI mockup shows "every 7d" as a time window option. Supporting larger windows is valuable — for example, a user might set a weekly traffic alert to track projected monthly usage. However, larger windows interact with ClickHouse data retention in ways worth discussing. + +### How alert evaluation works with ClickHouse + +Each alert evaluation compares two time windows: +- **Current window**: the most recent N minutes of data +- **Previous window**: the N minutes before that (used as the baseline for comparison) + +This means the query looks back **2x the window size**. A 7-day alert looks 14 days back. + +### ClickHouse materialized view retention + +Our ClickHouse tables have different TTLs and granularities: + +| Table | Granularity | TTL | Max alert window (2x lookback) | +|-------|-------------|-----|-------------------------------| +| `operations_minutely` | 1-minute buckets | 24 hours | ~6 hours | +| `operations_hourly` | 1-hour buckets | 30 days | ~14 days | +| `operations_daily` | 1-day buckets | 1 year | ~6 months | + +The evaluation engine automatically selects the appropriate table based on window size. + +### Tradeoffs with larger windows + +**Granularity vs. sensitivity**: Larger windows require coarser-grained tables. A 7-day alert uses the hourly table, meaning data is aggregated in 1-hour buckets. A brief 10-minute latency spike would be smoothed into an hourly average and might not trigger the alert. For use cases like weekly traffic totals this is fine, but for spike detection shorter windows are more appropriate. + +**Evaluation frequency vs. window size**: The cron job runs every 5 minutes. For a 7-day window, the result shifts by 5 minutes out of 10,080 — consecutive evaluations produce nearly identical values. This is harmless but slightly wasteful. A future optimization could scale evaluation frequency with window size (e.g., hourly evaluation for daily/weekly alerts). + +**ClickHouse query cost**: The single-query optimization (fetching both windows in one `GROUP BY` query) works regardless of window size — it always returns 2 rows. Query cost scales with the number of distinct `(target, filter)` groups, not with window length. At ~100 groups, that's ~100 queries every 5 minutes, which is modest given ClickHouse's primary key efficiency (`target` is the first key, timestamp is in the sort order, and daily partitions auto-prune irrelevant data). + +### Recommendation + +Support window sizes from **5 minutes up to 14 days** (20,160 minutes). This covers: +- Short-term spike detection (5m–1h on minutely table) +- Medium-term trend monitoring (1h–6h on minutely table) +- Daily/weekly usage tracking (1d–14d on hourly table) + +The 14-day cap ensures the comparison window (28 days back) fits comfortably within the hourly table's 30-day TTL. If there's demand for 30-day windows in the future, those would fall to the daily table (1-year TTL) and could be added later. + +**Should we support a different range, or is 5 minutes to 14 days sufficient for V1?** + +--- + +## Open Question: Specialized ClickHouse Materialized View + +The existing `operations_minutely` table stores one row per `(target, hash, client_name, client_version, minute)`: + +``` +ORDER BY (target, hash, client_name, client_version, timestamp) +``` + +For a target-wide alert (no operation/client filters), the evaluation query must aggregate across all operation hashes and client combinations. A target with 500 operations and 10 client versions produces ~5,000 rows per minute. For a 30-minute window comparing current vs. previous, the query scans and merges **~300,000 rows** of `AggregateFunction` state. + +### Would a target-level MV help? + +A specialized materialized view pre-aggregated at the target level: + +```sql +CREATE MATERIALIZED VIEW default.operations_target_minutely +( + target LowCardinality(String) CODEC(ZSTD(1)), + timestamp DateTime('UTC') CODEC(DoubleDelta, LZ4), + total UInt32 CODEC(T64, ZSTD(1)), + total_ok UInt32 CODEC(T64, ZSTD(1)), + duration_avg AggregateFunction(avg, UInt64) CODEC(ZSTD(1)), + duration_quantiles AggregateFunction(quantiles(0.75, 0.9, 0.95, 0.99), UInt64) CODEC(ZSTD(1)) +) +ENGINE = SummingMergeTree +PRIMARY KEY (target) +ORDER BY (target, timestamp) +TTL timestamp + INTERVAL 24 HOUR +AS SELECT + target, + toStartOfMinute(timestamp) AS timestamp, + count() AS total, + sum(ok) AS total_ok, + avgState(duration) AS duration_avg, + quantilesState(0.75, 0.9, 0.95, 0.99)(duration) AS duration_quantiles +FROM default.operations +GROUP BY target, timestamp +``` + +This collapses the 5,000 rows/minute down to **1 row per (target, minute)**. The same 30-minute alert query would scan ~60 rows instead of ~300,000. + +### Tradeoffs + +**Benefits:** +- Dramatically fewer rows to scan for target-wide alerts (the common case) +- Simpler, faster merges — no hash/client dimensions to aggregate across +- Smaller on-disk footprint for this view + +**Costs:** +- Additional write amplification — every INSERT into `operations` triggers one more MV materialization +- Alerts with operation or client filters can't use this view — they still need `operations_minutely` to filter by `hash` or `client_name`/`client_version` +- One more table to maintain and migrate + +### Recommendation + +For V1, we could start with the existing `operations_minutely` table and measure actual query performance. The primary key starts with `target`, so ClickHouse can efficiently skip irrelevant data even without a dedicated view. If we observe query latency issues at scale, we add the specialized MV as an optimization. + +Alternatively, if we expect many target-wide alerts (likely the majority), adding the MV upfront avoids a future ClickHouse migration. + +**Should we add a target-level MV now, or start with existing tables and optimize later?** + +--- + +## Performance & Scaling + +### Evaluation architecture + +A **single cron job** runs every minute and evaluates **all** enabled metric alerts. It does not create one job per alert. The process: + +1. One PostgreSQL query fetches all enabled alerts (joined with channels, targets, orgs) +2. Alerts are grouped by `(target_id, time_window_minutes, filter)` — alerts sharing the same group are served by a **single ClickHouse query** +3. Each ClickHouse query returns both time windows and all metrics (latency percentiles, totals, ok counts) in one result set, serving multiple alert types simultaneously + +### How it scales + +Query count scales with **unique groups**, not alert count. Three alerts on the same target (e.g., latency + errors + traffic with the same window and no filter) cost exactly one ClickHouse query. + +| Scenario | Configured alerts | Unique groups | CH queries/tick | Est. time (5 concurrent) | +|----------|-------------------|---------------|-----------------|--------------------------| +| Small | 10 | ~5 | 5 | ~10ms | +| Medium | 100 | ~30 | 30 | ~60ms | +| Large | 1,000 | ~150 | 150 | ~300ms | + +Even at 1,000 alerts, evaluation completes in well under a second. The practical ceiling is the ClickHouse connection pool (32 sockets) and the 60-second task timeout. + +### Safeguards + +- **Task timeout**: 60 seconds. Graphile-Worker deduplicates cron tasks, so an overrunning evaluation won't spawn a second concurrent instance. +- **Query timeout**: 10 seconds per ClickHouse query (matching existing timeouts in `operations-reader.ts`). +- **Bounded concurrency**: ClickHouse queries execute with `p-queue` concurrency of 5 to avoid saturating the connection pool. + +--- + +## Verification + +### Phase 1 +1. Run migration, verify `alert_channel_type` enum has `EMAIL` and `email_addresses` column exists +2. Create an EMAIL channel via GraphQL playground, verify it persists +3. Create a schema-change alert using the EMAIL channel, publish a schema change, verify email is sent +4. Verify frontend form shows email option and validates correctly + +### Phase 2 +1. Run migration, verify `metric_alerts` table created +2. CRUD metric alerts via GraphQL playground +3. Trigger `evaluateMetricAlerts` task manually, verify ClickHouse queries and state transitions +4. Create a webhook metric alert, simulate threshold breach, verify webhook receives payload +5. Add integration test in `integration-tests/tests/api/project/alerts.spec.ts` From 0be673a727a654cbb3a366923deec21ad20e5079 Mon Sep 17 00:00:00 2001 From: Jonathan Brennan Date: Mon, 30 Mar 2026 14:55:08 -0500 Subject: [PATCH 02/18] gemini suggestions --- .../alerts-and-notifications-improvements.md | 51 ++++++++++++++++--- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/packages/web/app/.claude/plans/alerts-and-notifications-improvements.md b/packages/web/app/.claude/plans/alerts-and-notifications-improvements.md index b35e397fb7..b791a60c4c 100644 --- a/packages/web/app/.claude/plans/alerts-and-notifications-improvements.md +++ b/packages/web/app/.claude/plans/alerts-and-notifications-improvements.md @@ -34,6 +34,19 @@ ALTER TYPE alert_channel_type ADD VALUE 'EMAIL'; ALTER TABLE alert_channels ADD COLUMN email_addresses TEXT[]; ``` +**Important:** This migration must use `noTransaction: true` because PostgreSQL does not allow `ALTER TYPE ... ADD VALUE` inside a transaction block. The migration runner (`pg-migrator.ts`) wraps migrations in transactions by default — the `noTransaction` flag opts out. There are 18+ existing migrations using this pattern (e.g., index creation with `CREATE INDEX CONCURRENTLY`). Note: the existing MS Teams migration (`2024.06.11T10-10-00.ms-teams-webhook.ts`) is missing this flag, which is a latent bug. + +```typescript +export default { + name: '2026.03.27T00-00-00.email-alert-channel.ts', + noTransaction: true, + run: ({ sql }) => [ + { name: 'Add EMAIL to alert_channel_type', query: sql`ALTER TYPE alert_channel_type ADD VALUE 'EMAIL'` }, + { name: 'Add email_addresses column', query: sql`ALTER TABLE alert_channels ADD COLUMN email_addresses TEXT[]` }, + ], +} satisfies MigrationExecutor; +``` + Register in `packages/migrations/src/run-pg-migrations.ts`. ### 1.2 Data Access @@ -353,23 +366,45 @@ The ingestion pipeline (API buffer → Kafka → ClickHouse async insert) has a **Key optimization**: Fetch both windows and all metrics in a **single query** per `(target, filter)` group. This serves latency, error rate, and traffic alerts simultaneously and halves round-trips by returning both the current and previous window in one result. +The query uses **explicit sliding windows** rather than `toStartOfInterval` bucketing. Using `toStartOfInterval` would snap to fixed time boundaries, producing partial buckets at the edges (and potentially 3 rows instead of 2), leading to incorrect metric calculations. Instead, we define two exact non-overlapping ranges and use a `CASE` expression to label each row: + +``` +now = current time +offset = 1 minute (ingestion pipeline latency buffer) +W = windowMinutes + +currentWindow: [now - offset - W, now - offset) +previousWindow: [now - offset - 2W, now - offset - W) +``` + ```sql SELECT - toStartOfInterval(timestamp, INTERVAL {windowMinutes} MINUTE) as window, + CASE + WHEN timestamp >= {currentWindowStart} THEN 'current' + ELSE 'previous' + END as window, sum(total) as total, sum(total_ok) as total_ok, avgMerge(duration_avg) as average, quantilesMerge(0.75, 0.90, 0.95, 0.99)(duration_quantiles) as percentiles FROM operations_minutely WHERE target = {targetId} - AND timestamp >= {previousWindowStart} AND timestamp < {currentWindowEnd} + AND timestamp >= {previousWindowStart} + AND timestamp < {currentWindowEnd} [AND hash IN/NOT IN ({operationIds})] [AND (client_name, client_version) IN/NOT IN ({clientFilters})] GROUP BY window ORDER BY window ``` -Returns 2 rows (previous and current window). From these: +Where the boundaries are computed as: +- `currentWindowEnd = now - 1 minute` +- `currentWindowStart = now - 1 minute - W` +- `previousWindowStart = now - 1 minute - 2W` + +This always returns exactly 2 rows (one per label), each aggregating a complete window with no partial-bucket artifacts. + +From these: - **Latency**: pick the relevant percentile or average from each row - **Error rate**: `(total - total_ok) / total * 100` per row - **Traffic**: `total` per row @@ -379,7 +414,11 @@ Returns 2 rows (previous and current window). From these: - **FIXED_VALUE**: `currentValue > thresholdValue` (or `<` for BELOW direction) - **PERCENTAGE_CHANGE**: `((currentValue - previousValue) / previousValue) * 100 > thresholdValue` -Edge cases: skip if both windows have 0 data; treat as absolute if previous is 0 with relative threshold. +**Edge cases:** +- **Both windows have 0 data**: Skip evaluation entirely (no meaningful comparison possible). +- **Previous window is 0, current is > 0 (PERCENTAGE_CHANGE)**: Division by zero. Fall back to FIXED_VALUE comparison against the threshold — i.e., check `currentValue > thresholdValue` directly. This avoids a runtime error while still alerting on a meaningful spike from zero baseline. +- **Previous window is 0, current is 0 (PERCENTAGE_CHANGE)**: No change, treat as OK. +- **Current window has data but previous doesn't exist** (e.g., alert was just created): Skip evaluation until both windows have data. ### 2.5 Notifications from Workflows @@ -464,9 +503,9 @@ The evaluation engine automatically selects the appropriate table based on windo **Granularity vs. sensitivity**: Larger windows require coarser-grained tables. A 7-day alert uses the hourly table, meaning data is aggregated in 1-hour buckets. A brief 10-minute latency spike would be smoothed into an hourly average and might not trigger the alert. For use cases like weekly traffic totals this is fine, but for spike detection shorter windows are more appropriate. -**Evaluation frequency vs. window size**: The cron job runs every 5 minutes. For a 7-day window, the result shifts by 5 minutes out of 10,080 — consecutive evaluations produce nearly identical values. This is harmless but slightly wasteful. A future optimization could scale evaluation frequency with window size (e.g., hourly evaluation for daily/weekly alerts). +**Evaluation frequency vs. window size**: The cron job runs every minute (see section 2.4). For a 7-day window, the result shifts by 1 minute out of 10,080 — consecutive evaluations produce nearly identical values. This is harmless but slightly wasteful. A future optimization could scale evaluation frequency with window size (e.g., hourly evaluation for daily/weekly alerts). -**ClickHouse query cost**: The single-query optimization (fetching both windows in one `GROUP BY` query) works regardless of window size — it always returns 2 rows. Query cost scales with the number of distinct `(target, filter)` groups, not with window length. At ~100 groups, that's ~100 queries every 5 minutes, which is modest given ClickHouse's primary key efficiency (`target` is the first key, timestamp is in the sort order, and daily partitions auto-prune irrelevant data). +**ClickHouse query cost**: The single-query optimization (fetching both windows in one `CASE`-labeled query) works regardless of window size — it always returns 2 rows. Query cost scales with the number of distinct `(target, filter)` groups, not with window length. At ~100 groups, that's ~100 queries per minute, which is modest given ClickHouse's primary key efficiency (`target` is the first key, timestamp is in the sort order, and daily partitions auto-prune irrelevant data). ### Recommendation From a7b778ebb793e061f08c67d39dae6a853b2fef5b Mon Sep 17 00:00:00 2001 From: Jonathan Brennan Date: Mon, 30 Mar 2026 14:55:33 -0500 Subject: [PATCH 03/18] prettier --- .../alerts-and-notifications-improvements.md | 512 ++++++++++++------ 1 file changed, 345 insertions(+), 167 deletions(-) diff --git a/packages/web/app/.claude/plans/alerts-and-notifications-improvements.md b/packages/web/app/.claude/plans/alerts-and-notifications-improvements.md index b791a60c4c..0fa9c4326d 100644 --- a/packages/web/app/.claude/plans/alerts-and-notifications-improvements.md +++ b/packages/web/app/.claude/plans/alerts-and-notifications-improvements.md @@ -2,56 +2,81 @@ ## Motivation -The existing alerts system only supports schema change notifications via Slack, Webhook, and MS Teams. Users have no way to be alerted when their GraphQL API's latency spikes, error rate degrades, or traffic volume changes unexpectedly. These are the most valuable alert types from a production monitoring perspective. +The existing alerts system only supports schema change notifications via Slack, Webhook, and MS +Teams. Users have no way to be alerted when their GraphQL API's latency spikes, error rate degrades, +or traffic volume changes unexpectedly. These are the most valuable alert types from a production +monitoring perspective. We also lack email as a notification channel, which is table stakes for an alerting system. This proposal covers two workstreams: -1. **Email alert channel** — Add EMAIL as a new notification channel type, benefiting both existing schema-change alerts and the new metric alerts -2. **Metric-based alerts** — Add configurable alerts for latency, error rate, and traffic with periodic evaluation against ClickHouse data +1. **Email alert channel** — Add EMAIL as a new notification channel type, benefiting both existing + schema-change alerts and the new metric alerts +2. **Metric-based alerts** — Add configurable alerts for latency, error rate, and traffic with + periodic evaluation against ClickHouse data ### Design decisions made so far -- **Resolved notifications**: Send a "resolved" notification when a metric alert transitions from FIRING back to OK -- **Alert scoping**: Metric alerts can optionally be scoped to a specific insights filter (operation IDs and/or client name+version combinations) +- **Resolved notifications**: Send a "resolved" notification when a metric alert transitions from + FIRING back to OK +- **Alert scoping**: Metric alerts can optionally be scoped to a specific insights filter (operation + IDs and/or client name+version combinations) - **Multiple email recipients**: Email channels support an array of addresses -- **Severity levels**: Alerts carry a user-defined severity label (info, warning, critical) for organizational purposes +- **Severity levels**: Alerts carry a user-defined severity label (info, warning, critical) for + organizational purposes --- ## Phase 1: Email Alert Channel -Follows the exact same pattern used when MS Teams was added (`2024.06.11T10-10-00.ms-teams-webhook.ts`). +Follows the exact same pattern used when MS Teams was added +(`2024.06.11T10-10-00.ms-teams-webhook.ts`). ### 1.1 Database Migration **New file:** `packages/migrations/src/actions/2026.03.27T00-00-00.email-alert-channel.ts` ```sql -ALTER TYPE alert_channel_type ADD VALUE 'EMAIL'; +ALTER TYPE alert_channel_type +ADD VALUE 'EMAIL'; + -- Array of email addresses to support multiple recipients per channel -ALTER TABLE alert_channels ADD COLUMN email_addresses TEXT[]; +ALTER TABLE alert_channels +ADD COLUMN email_addresses TEXT []; ``` -**Important:** This migration must use `noTransaction: true` because PostgreSQL does not allow `ALTER TYPE ... ADD VALUE` inside a transaction block. The migration runner (`pg-migrator.ts`) wraps migrations in transactions by default — the `noTransaction` flag opts out. There are 18+ existing migrations using this pattern (e.g., index creation with `CREATE INDEX CONCURRENTLY`). Note: the existing MS Teams migration (`2024.06.11T10-10-00.ms-teams-webhook.ts`) is missing this flag, which is a latent bug. +**Important:** This migration must use `noTransaction: true` because PostgreSQL does not allow +`ALTER TYPE ... ADD VALUE` inside a transaction block. The migration runner (`pg-migrator.ts`) wraps +migrations in transactions by default — the `noTransaction` flag opts out. There are 18+ existing +migrations using this pattern (e.g., index creation with `CREATE INDEX CONCURRENTLY`). Note: the +existing MS Teams migration (`2024.06.11T10-10-00.ms-teams-webhook.ts`) is missing this flag, which +is a latent bug. ```typescript export default { name: '2026.03.27T00-00-00.email-alert-channel.ts', noTransaction: true, run: ({ sql }) => [ - { name: 'Add EMAIL to alert_channel_type', query: sql`ALTER TYPE alert_channel_type ADD VALUE 'EMAIL'` }, - { name: 'Add email_addresses column', query: sql`ALTER TABLE alert_channels ADD COLUMN email_addresses TEXT[]` }, - ], -} satisfies MigrationExecutor; + { + name: 'Add EMAIL to alert_channel_type', + query: sql`ALTER TYPE alert_channel_type ADD VALUE 'EMAIL'` + }, + { + name: 'Add email_addresses column', + query: sql`ALTER TABLE alert_channels ADD COLUMN email_addresses TEXT[]` + } + ] +} satisfies MigrationExecutor ``` Register in `packages/migrations/src/run-pg-migrations.ts`. ### 1.2 Data Access -The legacy storage service (`packages/services/storage/src/index.ts`) will not be extended. Instead, following the modern pattern used by recent modules (app-deployments, schema-proposals, saved-filters), we create a module-level provider that injects `PG_POOL_CONFIG` directly. +The legacy storage service (`packages/services/storage/src/index.ts`) will not be extended. Instead, +following the modern pattern used by recent modules (app-deployments, schema-proposals, +saved-filters), we create a module-level provider that injects `PG_POOL_CONFIG` directly. **New file:** `packages/services/api/src/modules/alerts/providers/alert-channels-storage.ts` @@ -66,9 +91,11 @@ export class AlertChannelsStorage { } ``` -This provider takes over alert channel CRUD from the legacy storage module. Existing callers in `AlertsManager` are updated to use this new provider instead. +This provider takes over alert channel CRUD from the legacy storage module. Existing callers in +`AlertsManager` are updated to use this new provider instead. -**File:** `packages/services/api/src/shared/entities.ts` — add `emailAddresses: string[] | null` to `AlertChannel` interface. +**File:** `packages/services/api/src/shared/entities.ts` — add `emailAddresses: string[] | null` to +`AlertChannel` interface. ### 1.3 GraphQL API @@ -101,20 +128,24 @@ input AddAlertChannelInput { ### 1.4 Resolvers **New file:** `packages/services/api/src/modules/alerts/resolvers/AlertEmailChannel.ts` + ```typescript export const AlertEmailChannel: AlertEmailChannelResolvers = { __isTypeOf: channel => channel.type === 'EMAIL', - emails: channel => channel.emailAddresses ?? [], -}; + emails: channel => channel.emailAddresses ?? [] +} ``` **File:** `packages/services/api/src/modules/alerts/resolvers/Mutation/addAlertChannel.ts` -- Add Zod validation: `email: MaybeModel(z.object({ emails: z.array(z.string().email().max(255)).min(1).max(10) }))` + +- Add Zod validation: + `email: MaybeModel(z.object({ emails: z.array(z.string().email().max(255)).min(1).max(10) }))` - Pass `emailAddresses: input.email?.emails` to AlertsManager ### 1.5 AlertsManager **File:** `packages/services/api/src/modules/alerts/providers/alerts-manager.ts` + - Update `addChannel()` input to accept `emailAddresses?: string[] | null` - Pass through to storage - Update `triggerChannelConfirmation()` to handle EMAIL type @@ -124,25 +155,32 @@ export const AlertEmailChannel: AlertEmailChannelResolvers = { **New file:** `packages/services/api/src/modules/alerts/providers/adapters/email.ts` -Implements `CommunicationAdapter` interface. Uses `TaskScheduler` to schedule an email task in the workflows service (same pattern as `WebhookCommunicationAdapter` which schedules `SchemaChangeNotificationTask`). +Implements `CommunicationAdapter` interface. Uses `TaskScheduler` to schedule an email task in the +workflows service (same pattern as `WebhookCommunicationAdapter` which schedules +`SchemaChangeNotificationTask`). ```typescript @Injectable() export class EmailCommunicationAdapter implements CommunicationAdapter { - constructor(private taskScheduler: TaskScheduler, private logger: Logger) {} + constructor( + private taskScheduler: TaskScheduler, + private logger: Logger + ) {} async sendSchemaChangeNotification(input: SchemaChangeNotificationInput) { await this.taskScheduler.scheduleTask(AlertEmailNotificationTask, { recipients: input.channel.emailAddresses ?? [], - event: { /* schema change details */ }, - }); + event: { + /* schema change details */ + } + }) } async sendChannelConfirmation(input: ChannelConfirmationInput) { await this.taskScheduler.scheduleTask(AlertEmailConfirmationTask, { recipients: input.channel.emailAddresses ?? [], - event: input.event, - }); + event: input.event + }) } } ``` @@ -150,47 +188,55 @@ export class EmailCommunicationAdapter implements CommunicationAdapter { ### 1.7 Email Task + Template (Workflows Service) **New file:** `packages/services/workflows/src/tasks/alert-email-notification.ts` + - Define `AlertEmailNotificationTask` and `AlertEmailConfirmationTask` -- Send emails using `context.email.send()` with MJML templates (same pattern as `email-verification.ts`) +- Send emails using `context.email.send()` with MJML templates (same pattern as + `email-verification.ts`) **New file:** `packages/services/workflows/src/lib/emails/templates/alert-notification.ts` -- MJML email template for schema change notifications (use existing `email()`, `paragraph()`, `button()` helpers from `components.ts`) + +- MJML email template for schema change notifications (use existing `email()`, `paragraph()`, + `button()` helpers from `components.ts`) **File:** `packages/services/workflows/src/index.ts` — register new task module ### 1.8 Module Registration -**File:** `packages/services/api/src/modules/alerts/index.ts` — add `EmailCommunicationAdapter` to providers +**File:** `packages/services/api/src/modules/alerts/index.ts` — add `EmailCommunicationAdapter` to +providers ### 1.9 Frontend **File:** `packages/web/app/src/components/project/alerts/create-channel.tsx` -- Add email addresses field with Zod validation (the existing form uses Yup + Formik, but new form code should use Zod + react-hook-form to match the current codebase convention) + +- Add email addresses field with Zod validation (the existing form uses Yup + Formik, but new form + code should use Zod + react-hook-form to match the current codebase convention) - Show email input when type === EMAIL - Pass `email: { emails: values.emailAddresses }` in mutation **File:** `packages/web/app/src/components/project/alerts/channels-table.tsx` + - Handle `AlertEmailChannel` typename to display the email addresses ### Key files for Phase 1 -| File | Change | -|------|--------| -| `packages/migrations/src/actions/2026.03.27T00-00-00.email-alert-channel.ts` | **New** — migration | -| `packages/migrations/src/run-pg-migrations.ts` | Register migration | -| `packages/services/api/src/modules/alerts/providers/alert-channels-storage.ts` | **New** — module-level CRUD provider (replaces legacy storage) | -| `packages/services/api/src/shared/entities.ts` | Add emailAddresses to AlertChannel | -| `packages/services/api/src/modules/alerts/module.graphql.ts` | Add AlertEmailChannel type + EmailChannelInput | -| `packages/services/api/src/modules/alerts/resolvers/AlertEmailChannel.ts` | **New** — resolver | -| `packages/services/api/src/modules/alerts/resolvers/Mutation/addAlertChannel.ts` | Add email validation + input | -| `packages/services/api/src/modules/alerts/providers/alerts-manager.ts` | Handle EMAIL in dispatch | -| `packages/services/api/src/modules/alerts/providers/adapters/email.ts` | **New** — adapter | -| `packages/services/api/src/modules/alerts/index.ts` | Register adapter | -| `packages/services/workflows/src/tasks/alert-email-notification.ts` | **New** — email task | -| `packages/services/workflows/src/lib/emails/templates/alert-notification.ts` | **New** — MJML template | -| `packages/services/workflows/src/index.ts` | Register task | -| `packages/web/app/src/components/project/alerts/create-channel.tsx` | Add email form field | -| `packages/web/app/src/components/project/alerts/channels-table.tsx` | Display email channels | +| File | Change | +| -------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| `packages/migrations/src/actions/2026.03.27T00-00-00.email-alert-channel.ts` | **New** — migration | +| `packages/migrations/src/run-pg-migrations.ts` | Register migration | +| `packages/services/api/src/modules/alerts/providers/alert-channels-storage.ts` | **New** — module-level CRUD provider (replaces legacy storage) | +| `packages/services/api/src/shared/entities.ts` | Add emailAddresses to AlertChannel | +| `packages/services/api/src/modules/alerts/module.graphql.ts` | Add AlertEmailChannel type + EmailChannelInput | +| `packages/services/api/src/modules/alerts/resolvers/AlertEmailChannel.ts` | **New** — resolver | +| `packages/services/api/src/modules/alerts/resolvers/Mutation/addAlertChannel.ts` | Add email validation + input | +| `packages/services/api/src/modules/alerts/providers/alerts-manager.ts` | Handle EMAIL in dispatch | +| `packages/services/api/src/modules/alerts/providers/adapters/email.ts` | **New** — adapter | +| `packages/services/api/src/modules/alerts/index.ts` | Register adapter | +| `packages/services/workflows/src/tasks/alert-email-notification.ts` | **New** — email task | +| `packages/services/workflows/src/lib/emails/templates/alert-notification.ts` | **New** — MJML template | +| `packages/services/workflows/src/index.ts` | Register task | +| `packages/web/app/src/components/project/alerts/create-channel.tsx` | Add email form field | +| `packages/web/app/src/components/project/alerts/channels-table.tsx` | Display email channels | --- @@ -198,80 +244,106 @@ export class EmailCommunicationAdapter implements CommunicationAdapter { ### Why a new table? -The existing `alerts` table is tightly coupled to schema-change notifications — it has a fixed `alert_type` enum with only `SCHEMA_CHANGE_NOTIFICATIONS`, and no configuration columns. Metric alerts need substantially different configuration: time windows, metric selectors, threshold types/values, comparison direction, severity, evaluation state, and optional operation/client filters. +The existing `alerts` table is tightly coupled to schema-change notifications — it has a fixed +`alert_type` enum with only `SCHEMA_CHANGE_NOTIFICATIONS`, and no configuration columns. Metric +alerts need substantially different configuration: time windows, metric selectors, threshold +types/values, comparison direction, severity, evaluation state, and optional operation/client +filters. -A new `metric_alerts` table keeps both systems clean and independently evolvable. It reuses the existing `alert_channels` table (including the new EMAIL type from Phase 1) for notification delivery. +A new `metric_alerts` table keeps both systems clean and independently evolvable. It reuses the +existing `alert_channels` table (including the new EMAIL type from Phase 1) for notification +delivery. ### 2.1 Database Migration **New file:** `packages/migrations/src/actions/2026.03.27T00-00-01.metric-alerts.ts` ```sql -CREATE TYPE metric_alert_type AS ENUM ('LATENCY', 'ERROR_RATE', 'TRAFFIC'); -CREATE TYPE metric_alert_metric AS ENUM ('avg', 'p75', 'p90', 'p95', 'p99'); -CREATE TYPE metric_alert_threshold_type AS ENUM ('FIXED_VALUE', 'PERCENTAGE_CHANGE'); -CREATE TYPE metric_alert_direction AS ENUM ('ABOVE', 'BELOW'); -CREATE TYPE metric_alert_severity AS ENUM ('INFO', 'WARNING', 'CRITICAL'); +CREATE TYPE metric_alert_type AS ENUM('LATENCY', 'ERROR_RATE', 'TRAFFIC'); + +CREATE TYPE metric_alert_metric AS ENUM('avg', 'p75', 'p90', 'p95', 'p99'); + +CREATE TYPE metric_alert_threshold_type AS ENUM('FIXED_VALUE', 'PERCENTAGE_CHANGE'); + +CREATE TYPE metric_alert_direction AS ENUM('ABOVE', 'BELOW'); + +CREATE TYPE metric_alert_severity AS ENUM('INFO', 'WARNING', 'CRITICAL'); -- Alert configuration (what to monitor and how) CREATE TABLE metric_alerts ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, - project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, - target_id UUID NOT NULL REFERENCES targets(id) ON DELETE CASCADE, - alert_channel_id UUID NOT NULL REFERENCES alert_channels(id) ON DELETE CASCADE, + id UUID PRIMARY KEY DEFAULT gen_random_uuid (), + organization_id UUID NOT NULL REFERENCES organizations (id) ON DELETE CASCADE, + project_id UUID NOT NULL REFERENCES projects (id) ON DELETE CASCADE, + target_id UUID NOT NULL REFERENCES targets (id) ON DELETE CASCADE, + alert_channel_id UUID NOT NULL REFERENCES alert_channels (id) ON DELETE CASCADE, type metric_alert_type NOT NULL, time_window_minutes INT NOT NULL DEFAULT 30, - metric metric_alert_metric, -- only for LATENCY type + metric metric_alert_metric, -- only for LATENCY type threshold_type metric_alert_threshold_type NOT NULL, threshold_value DOUBLE PRECISION NOT NULL, direction metric_alert_direction NOT NULL DEFAULT 'ABOVE', severity metric_alert_severity NOT NULL DEFAULT 'WARNING', name TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - enabled BOOLEAN NOT NULL DEFAULT true, + enabled BOOLEAN NOT NULL DEFAULT TRUE, last_evaluated_at TIMESTAMPTZ, -- Optional insights filter scoping (stored as JSONB) -- Matches OperationStatsFilterInput: { operationIds?, excludeOperations?, -- clientVersionFilters?: [{clientName, versions?}], excludeClientVersionFilters? } - filter JSONB, + FILTER JSONB, CONSTRAINT metric_alerts_metric_required CHECK ( - (type = 'LATENCY' AND metric IS NOT NULL) OR (type != 'LATENCY' AND metric IS NULL) + ( + type = 'LATENCY' + AND metric IS NOT NULL + ) + OR ( + type != 'LATENCY' + AND metric IS NULL + ) ) ); -CREATE INDEX idx_metric_alerts_enabled ON metric_alerts(enabled) WHERE enabled = true; +CREATE INDEX idx_metric_alerts_enabled ON metric_alerts (enabled) +WHERE + enabled = TRUE; -- Alert incident history (each time an alert fires and resolves) CREATE TABLE metric_alert_incidents ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - metric_alert_id UUID NOT NULL REFERENCES metric_alerts(id) ON DELETE CASCADE, + id UUID PRIMARY KEY DEFAULT gen_random_uuid (), + metric_alert_id UUID NOT NULL REFERENCES metric_alerts (id) ON DELETE CASCADE, started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - resolved_at TIMESTAMPTZ, -- NULL while still firing + resolved_at TIMESTAMPTZ, -- NULL while still firing current_value DOUBLE PRECISION NOT NULL, previous_value DOUBLE PRECISION, - threshold_value DOUBLE PRECISION NOT NULL -- snapshot of threshold at time of incident + threshold_value DOUBLE PRECISION NOT NULL -- snapshot of threshold at time of incident ); -CREATE INDEX idx_metric_alert_incidents_alert ON metric_alert_incidents(metric_alert_id); -CREATE INDEX idx_metric_alert_incidents_open ON metric_alert_incidents(metric_alert_id) - WHERE resolved_at IS NULL; +CREATE INDEX idx_metric_alert_incidents_alert ON metric_alert_incidents (metric_alert_id); + +CREATE INDEX idx_metric_alert_incidents_open ON metric_alert_incidents (metric_alert_id) +WHERE + resolved_at IS NULL; ``` ### 2.2 Data Access **New file:** `packages/services/api/src/modules/alerts/providers/metric-alerts-storage.ts` -Module-level provider with direct `PG_POOL_CONFIG` injection (same modern pattern as Phase 1). Provides: +Module-level provider with direct `PG_POOL_CONFIG` injection (same modern pattern as Phase 1). +Provides: Alert configuration CRUD: + - `addMetricAlert(input)`, `updateMetricAlert(id, fields)`, `deleteMetricAlerts(ids)` - `getMetricAlerts(projectId)`, `getMetricAlertsByTarget(targetId)` - `getAllEnabledMetricAlerts()` — used by the workflows evaluation task Incident management: -- `createIncident(alertId, currentValue, previousValue, thresholdValue)` — called when alert transitions OK → FIRING -- `resolveIncident(alertId)` — sets `resolved_at` on the open incident when alert transitions FIRING → OK + +- `createIncident(alertId, currentValue, previousValue, thresholdValue)` — called when alert + transitions OK → FIRING +- `resolveIncident(alertId)` — sets `resolved_at` on the open incident when alert transitions FIRING + → OK - `getOpenIncident(alertId)` — find currently firing incident (where `resolved_at IS NULL`) - `getIncidentHistory(alertId, limit, offset)` — paginated history for the UI @@ -280,11 +352,31 @@ Incident management: **File:** `packages/services/api/src/modules/alerts/module.graphql.ts` ```graphql -enum MetricAlertType { LATENCY, ERROR_RATE, TRAFFIC } -enum MetricAlertMetric { avg, p75, p90, p95, p99 } -enum MetricAlertThresholdType { FIXED_VALUE, PERCENTAGE_CHANGE } -enum MetricAlertDirection { ABOVE, BELOW } -enum MetricAlertSeverity { INFO, WARNING, CRITICAL } +enum MetricAlertType { + LATENCY + ERROR_RATE + TRAFFIC +} +enum MetricAlertMetric { + avg + p75 + p90 + p95 + p99 +} +enum MetricAlertThresholdType { + FIXED_VALUE + PERCENTAGE_CHANGE +} +enum MetricAlertDirection { + ABOVE + BELOW +} +enum MetricAlertSeverity { + INFO + WARNING + CRITICAL +} type MetricAlert { id: ID! @@ -302,11 +394,17 @@ type MetricAlert { lastEvaluatedAt: DateTime createdAt: DateTime! filter: OperationStatsFilterInput - """Whether this alert is currently firing (has an open incident)""" + """ + Whether this alert is currently firing (has an open incident) + """ isFiring: Boolean! - """The currently open incident, if any""" + """ + The currently open incident, if any + """ currentIncident: MetricAlertIncident - """Past incidents for this alert""" + """ + Past incidents for this alert + """ incidents(first: Int, after: String): MetricAlertIncidentConnection! } @@ -319,13 +417,16 @@ type MetricAlertIncident { thresholdValue: Float! } -extend type Project { metricAlerts: [MetricAlert!] } +extend type Project { + metricAlerts: [MetricAlert!] +} # Mutations: addMetricAlert, updateMetricAlert, deleteMetricAlerts # (standard ok/error result pattern) ``` -**New resolver files:** `MetricAlert.ts`, `Mutation/addMetricAlert.ts`, `Mutation/updateMetricAlert.ts`, `Mutation/deleteMetricAlerts.ts` +**New resolver files:** `MetricAlert.ts`, `Mutation/addMetricAlert.ts`, +`Mutation/updateMetricAlert.ts`, `Mutation/deleteMetricAlerts.ts` Uses `alert:modify` permission. @@ -333,15 +434,21 @@ Uses `alert:modify` permission. #### Why the workflows service? -The workflows service (`packages/services/workflows/`) is our existing background job runner, built on Graphile-Worker with PostgreSQL-backed task queues. It already runs periodic cron jobs (cleanup tasks, etc.) and one-off tasks scheduled by the API (emails, webhooks). Metric alert evaluation — a periodic job that queries data and sends notifications — is exactly what this service was built for. +The workflows service (`packages/services/workflows/`) is our existing background job runner, built +on Graphile-Worker with PostgreSQL-backed task queues. It already runs periodic cron jobs (cleanup +tasks, etc.) and one-off tasks scheduled by the API (emails, webhooks). Metric alert evaluation — a +periodic job that queries data and sends notifications — is exactly what this service was built for. #### Add ClickHouse to Workflows -The workflows service currently only has PostgreSQL access. We need to add a lightweight ClickHouse HTTP client so it can query operations metrics. +The workflows service currently only has PostgreSQL access. We need to add a lightweight ClickHouse +HTTP client so it can query operations metrics. -- `packages/services/workflows/src/environment.ts` — add `CLICKHOUSE_HOST`, `CLICKHOUSE_PORT`, `CLICKHOUSE_USERNAME`, `CLICKHOUSE_PASSWORD` +- `packages/services/workflows/src/environment.ts` — add `CLICKHOUSE_HOST`, `CLICKHOUSE_PORT`, + `CLICKHOUSE_USERNAME`, `CLICKHOUSE_PASSWORD` - `packages/services/workflows/src/context.ts` — add `clickhouse` to Context -- `packages/services/workflows/src/lib/clickhouse-client.ts` — **new**, simple fetch-based HTTP client (the API's ClickHouse client is DI-heavy; we just need raw query execution) +- `packages/services/workflows/src/lib/clickhouse-client.ts` — **new**, simple fetch-based HTTP + client (the API's ClickHouse client is DI-heavy; we just need raw query execution) - `packages/services/workflows/src/index.ts` — instantiate and inject #### Evaluation Task @@ -350,23 +457,33 @@ The workflows service currently only has PostgreSQL access. We need to add a lig Cron: `* * * * * evaluateMetricAlerts` (every minute) -The ingestion pipeline (API buffer → Kafka → ClickHouse async insert) has a worst-case latency of ~31 seconds, so by the time each 1-minute cron tick fires, the previous minute's data is reliably available. This gives on-call engineers a worst-case ~2 minute detection time. +The ingestion pipeline (API buffer → Kafka → ClickHouse async insert) has a worst-case latency of +~31 seconds, so by the time each 1-minute cron tick fires, the previous minute's data is reliably +available. This gives on-call engineers a worst-case ~2 minute detection time. -1. Fetch all enabled metric alerts from PostgreSQL (join with `alert_channels`, `targets`, `projects`, `organizations`) +1. Fetch all enabled metric alerts from PostgreSQL (join with `alert_channels`, `targets`, + `projects`, `organizations`) 2. Group by `(target_id, time_window_minutes, filter)` to batch ClickHouse queries -3. Query ClickHouse for current window and previous window (with 1-minute offset to account for ingestion pipeline latency) +3. Query ClickHouse for current window and previous window (with 1-minute offset to account for + ingestion pipeline latency) 4. Compare metric values against thresholds 5. State transitions (determined by whether an open incident exists for the alert): - **OK → FIRING**: create a new incident row, send notification, update `last_evaluated_at` - **FIRING → FIRING**: no notification (prevent spam), update `last_evaluated_at` - - **FIRING → OK**: set `resolved_at` on the open incident, send "resolved" notification, update `last_evaluated_at` + - **FIRING → OK**: set `resolved_at` on the open incident, send "resolved" notification, update + `last_evaluated_at` - **OK → OK**: update `last_evaluated_at` only #### ClickHouse Query Design -**Key optimization**: Fetch both windows and all metrics in a **single query** per `(target, filter)` group. This serves latency, error rate, and traffic alerts simultaneously and halves round-trips by returning both the current and previous window in one result. +**Key optimization**: Fetch both windows and all metrics in a **single query** per +`(target, filter)` group. This serves latency, error rate, and traffic alerts simultaneously and +halves round-trips by returning both the current and previous window in one result. -The query uses **explicit sliding windows** rather than `toStartOfInterval` bucketing. Using `toStartOfInterval` would snap to fixed time boundaries, producing partial buckets at the edges (and potentially 3 rows instead of 2), leading to incorrect metric calculations. Instead, we define two exact non-overlapping ranges and use a `CASE` expression to label each row: +The query uses **explicit sliding windows** rather than `toStartOfInterval` bucketing. Using +`toStartOfInterval` would snap to fixed time boundaries, producing partial buckets at the edges (and +potentially 3 rows instead of 2), leading to incorrect metric calculations. Instead, we define two +exact non-overlapping ranges and use a `CASE` expression to label each row: ``` now = current time @@ -398,13 +515,16 @@ ORDER BY window ``` Where the boundaries are computed as: + - `currentWindowEnd = now - 1 minute` - `currentWindowStart = now - 1 minute - W` - `previousWindowStart = now - 1 minute - 2W` -This always returns exactly 2 rows (one per label), each aggregating a complete window with no partial-bucket artifacts. +This always returns exactly 2 rows (one per label), each aggregating a complete window with no +partial-bucket artifacts. From these: + - **Latency**: pick the relevant percentile or average from each row - **Error rate**: `(total - total_ok) / total * 100` per row - **Traffic**: `total` per row @@ -415,73 +535,94 @@ From these: - **PERCENTAGE_CHANGE**: `((currentValue - previousValue) / previousValue) * 100 > thresholdValue` **Edge cases:** + - **Both windows have 0 data**: Skip evaluation entirely (no meaningful comparison possible). -- **Previous window is 0, current is > 0 (PERCENTAGE_CHANGE)**: Division by zero. Fall back to FIXED_VALUE comparison against the threshold — i.e., check `currentValue > thresholdValue` directly. This avoids a runtime error while still alerting on a meaningful spike from zero baseline. +- **Previous window is 0, current is > 0 (PERCENTAGE_CHANGE)**: Division by zero. Fall back to + FIXED_VALUE comparison against the threshold — i.e., check `currentValue > thresholdValue` + directly. This avoids a runtime error while still alerting on a meaningful spike from zero + baseline. - **Previous window is 0, current is 0 (PERCENTAGE_CHANGE)**: No change, treat as OK. -- **Current window has data but previous doesn't exist** (e.g., alert was just created): Skip evaluation until both windows have data. +- **Current window has data but previous doesn't exist** (e.g., alert was just created): Skip + evaluation until both windows have data. ### 2.5 Notifications from Workflows **New file:** `packages/services/workflows/src/lib/metric-alert-notifier.ts` -Sends notifications directly from the workflows service (the API's DI container is not available here): +Sends notifications directly from the workflows service (the API's DI container is not available +here): + - **Webhooks**: Use existing `RequestBroker` / `send-webhook.ts` already in the workflows service -- **Slack**: Direct HTTP POST using `@slack/web-api`. The bot token is stored on the `organizations` table as `slack_token` and can be queried via PostgreSQL, which the workflows service already has access to. -- **Teams**: Direct HTTP POST to the webhook URL stored on the channel (simple fetch, same pattern as the API's `TeamsCommunicationAdapter`) +- **Slack**: Direct HTTP POST using `@slack/web-api`. The bot token is stored on the `organizations` + table as `slack_token` and can be queried via PostgreSQL, which the workflows service already has + access to. +- **Teams**: Direct HTTP POST to the webhook URL stored on the channel (simple fetch, same pattern + as the API's `TeamsCommunicationAdapter`) - **Email**: Use `context.email.send()` (from Phase 1 infrastructure) Example messages: Firing (Slack): -> :rotating_light: **Latency Alert: "API p99 Spike"** — Target: `my-target` in `my-project` -> p99 latency is **450ms** (was 200ms, +125%) — Threshold: above 200ms + +> :rotating_light: **Latency Alert: "API p99 Spike"** — Target: `my-target` in `my-project` p99 +> latency is **450ms** (was 200ms, +125%) — Threshold: above 200ms Resolved (Slack): + > :white_check_mark: **Resolved: "API p99 Spike"** — p99 latency is now **180ms** (threshold: 200ms) Webhook payload: + ```json { "type": "metric_alert", "state": "firing", "alert": { "name": "...", "type": "LATENCY", "metric": "p99", "severity": "warning" }, - "currentValue": 450, "previousValue": 200, "changePercent": 125, + "currentValue": 450, + "previousValue": 200, + "changePercent": 125, "threshold": { "type": "FIXED_VALUE", "value": 200, "direction": "ABOVE" }, "filter": { "operationIds": ["abc123"] }, - "target": { "slug": "..." }, "project": { "slug": "..." }, "organization": { "slug": "..." } + "target": { "slug": "..." }, + "project": { "slug": "..." }, + "organization": { "slug": "..." } } ``` ### 2.6 Deployment -**File:** `deployment/services/workflows.ts` — add ClickHouse env vars to the workflows service deployment config. +**File:** `deployment/services/workflows.ts` — add ClickHouse env vars to the workflows service +deployment config. ### Key files for Phase 2 -| File | Change | -|------|--------| -| `packages/migrations/src/actions/2026.03.27T00-00-01.metric-alerts.ts` | **New** — migration | +| File | Change | +| ----------------------------------------------------------------------------- | ------------------------------------ | +| `packages/migrations/src/actions/2026.03.27T00-00-01.metric-alerts.ts` | **New** — migration | | `packages/services/api/src/modules/alerts/providers/metric-alerts-storage.ts` | **New** — module-level CRUD provider | -| `packages/services/api/src/modules/alerts/module.graphql.ts` | Add MetricAlert types/mutations | -| `packages/services/api/src/modules/alerts/resolvers/` | New resolver files | -| `packages/services/api/src/modules/alerts/providers/alerts-manager.ts` | Add metric alert methods | -| `packages/services/workflows/src/environment.ts` | Add ClickHouse env vars | -| `packages/services/workflows/src/context.ts` | Add clickhouse to Context | -| `packages/services/workflows/src/lib/clickhouse-client.ts` | **New** — lightweight CH client | -| `packages/services/workflows/src/tasks/evaluate-metric-alerts.ts` | **New** — evaluation cron task | -| `packages/services/workflows/src/lib/metric-alert-notifier.ts` | **New** — notification sender | -| `packages/services/workflows/src/index.ts` | Register task + crontab | -| `deployment/services/workflows.ts` | ClickHouse env vars | +| `packages/services/api/src/modules/alerts/module.graphql.ts` | Add MetricAlert types/mutations | +| `packages/services/api/src/modules/alerts/resolvers/` | New resolver files | +| `packages/services/api/src/modules/alerts/providers/alerts-manager.ts` | Add metric alert methods | +| `packages/services/workflows/src/environment.ts` | Add ClickHouse env vars | +| `packages/services/workflows/src/context.ts` | Add clickhouse to Context | +| `packages/services/workflows/src/lib/clickhouse-client.ts` | **New** — lightweight CH client | +| `packages/services/workflows/src/tasks/evaluate-metric-alerts.ts` | **New** — evaluation cron task | +| `packages/services/workflows/src/lib/metric-alert-notifier.ts` | **New** — notification sender | +| `packages/services/workflows/src/index.ts` | Register task + crontab | +| `deployment/services/workflows.ts` | ClickHouse env vars | --- ## Open Question: Time Window Sizes and ClickHouse Data Retention -The UI mockup shows "every 7d" as a time window option. Supporting larger windows is valuable — for example, a user might set a weekly traffic alert to track projected monthly usage. However, larger windows interact with ClickHouse data retention in ways worth discussing. +The UI mockup shows "every 7d" as a time window option. Supporting larger windows is valuable — for +example, a user might set a weekly traffic alert to track projected monthly usage. However, larger +windows interact with ClickHouse data retention in ways worth discussing. ### How alert evaluation works with ClickHouse Each alert evaluation compares two time windows: + - **Current window**: the most recent N minutes of data - **Previous window**: the N minutes before that (used as the baseline for comparison) @@ -491,30 +632,43 @@ This means the query looks back **2x the window size**. A 7-day alert looks 14 d Our ClickHouse tables have different TTLs and granularities: -| Table | Granularity | TTL | Max alert window (2x lookback) | -|-------|-------------|-----|-------------------------------| -| `operations_minutely` | 1-minute buckets | 24 hours | ~6 hours | -| `operations_hourly` | 1-hour buckets | 30 days | ~14 days | -| `operations_daily` | 1-day buckets | 1 year | ~6 months | +| Table | Granularity | TTL | Max alert window (2x lookback) | +| --------------------- | ---------------- | -------- | ------------------------------ | +| `operations_minutely` | 1-minute buckets | 24 hours | ~6 hours | +| `operations_hourly` | 1-hour buckets | 30 days | ~14 days | +| `operations_daily` | 1-day buckets | 1 year | ~6 months | The evaluation engine automatically selects the appropriate table based on window size. ### Tradeoffs with larger windows -**Granularity vs. sensitivity**: Larger windows require coarser-grained tables. A 7-day alert uses the hourly table, meaning data is aggregated in 1-hour buckets. A brief 10-minute latency spike would be smoothed into an hourly average and might not trigger the alert. For use cases like weekly traffic totals this is fine, but for spike detection shorter windows are more appropriate. +**Granularity vs. sensitivity**: Larger windows require coarser-grained tables. A 7-day alert uses +the hourly table, meaning data is aggregated in 1-hour buckets. A brief 10-minute latency spike +would be smoothed into an hourly average and might not trigger the alert. For use cases like weekly +traffic totals this is fine, but for spike detection shorter windows are more appropriate. -**Evaluation frequency vs. window size**: The cron job runs every minute (see section 2.4). For a 7-day window, the result shifts by 1 minute out of 10,080 — consecutive evaluations produce nearly identical values. This is harmless but slightly wasteful. A future optimization could scale evaluation frequency with window size (e.g., hourly evaluation for daily/weekly alerts). +**Evaluation frequency vs. window size**: The cron job runs every minute (see section 2.4). For a +7-day window, the result shifts by 1 minute out of 10,080 — consecutive evaluations produce nearly +identical values. This is harmless but slightly wasteful. A future optimization could scale +evaluation frequency with window size (e.g., hourly evaluation for daily/weekly alerts). -**ClickHouse query cost**: The single-query optimization (fetching both windows in one `CASE`-labeled query) works regardless of window size — it always returns 2 rows. Query cost scales with the number of distinct `(target, filter)` groups, not with window length. At ~100 groups, that's ~100 queries per minute, which is modest given ClickHouse's primary key efficiency (`target` is the first key, timestamp is in the sort order, and daily partitions auto-prune irrelevant data). +**ClickHouse query cost**: The single-query optimization (fetching both windows in one +`CASE`-labeled query) works regardless of window size — it always returns 2 rows. Query cost scales +with the number of distinct `(target, filter)` groups, not with window length. At ~100 groups, +that's ~100 queries per minute, which is modest given ClickHouse's primary key efficiency (`target` +is the first key, timestamp is in the sort order, and daily partitions auto-prune irrelevant data). ### Recommendation Support window sizes from **5 minutes up to 14 days** (20,160 minutes). This covers: + - Short-term spike detection (5m–1h on minutely table) - Medium-term trend monitoring (1h–6h on minutely table) - Daily/weekly usage tracking (1d–14d on hourly table) -The 14-day cap ensures the comparison window (28 days back) fits comfortably within the hourly table's 30-day TTL. If there's demand for 30-day windows in the future, those would fall to the daily table (1-year TTL) and could be added later. +The 14-day cap ensures the comparison window (28 days back) fits comfortably within the hourly +table's 30-day TTL. If there's demand for 30-day windows in the future, those would fall to the +daily table (1-year TTL) and could be added later. **Should we support a different range, or is 5 minutes to 14 days sufficient for V1?** @@ -522,62 +676,75 @@ The 14-day cap ensures the comparison window (28 days back) fits comfortably wit ## Open Question: Specialized ClickHouse Materialized View -The existing `operations_minutely` table stores one row per `(target, hash, client_name, client_version, minute)`: +The existing `operations_minutely` table stores one row per +`(target, hash, client_name, client_version, minute)`: ``` ORDER BY (target, hash, client_name, client_version, timestamp) ``` -For a target-wide alert (no operation/client filters), the evaluation query must aggregate across all operation hashes and client combinations. A target with 500 operations and 10 client versions produces ~5,000 rows per minute. For a 30-minute window comparing current vs. previous, the query scans and merges **~300,000 rows** of `AggregateFunction` state. +For a target-wide alert (no operation/client filters), the evaluation query must aggregate across +all operation hashes and client combinations. A target with 500 operations and 10 client versions +produces ~5,000 rows per minute. For a 30-minute window comparing current vs. previous, the query +scans and merges **~300,000 rows** of `AggregateFunction` state. ### Would a target-level MV help? A specialized materialized view pre-aggregated at the target level: ```sql -CREATE MATERIALIZED VIEW default.operations_target_minutely -( - target LowCardinality(String) CODEC(ZSTD(1)), - timestamp DateTime('UTC') CODEC(DoubleDelta, LZ4), - total UInt32 CODEC(T64, ZSTD(1)), - total_ok UInt32 CODEC(T64, ZSTD(1)), - duration_avg AggregateFunction(avg, UInt64) CODEC(ZSTD(1)), - duration_quantiles AggregateFunction(quantiles(0.75, 0.9, 0.95, 0.99), UInt64) CODEC(ZSTD(1)) -) -ENGINE = SummingMergeTree -PRIMARY KEY (target) -ORDER BY (target, timestamp) -TTL timestamp + INTERVAL 24 HOUR -AS SELECT +CREATE MATERIALIZED VIEW DEFAULT.operations_target_minutely ( + target LowCardinality (STRING) CODEC (ZSTD (1)), + timestamp DateTime ('UTC') CODEC (DoubleDelta, LZ4), + total UInt32 CODEC (T64, ZSTD (1)), + total_ok UInt32 CODEC (T64, ZSTD (1)), + duration_avg AggregateFunction (AVG, UInt64) CODEC (ZSTD (1)), + duration_quantiles AggregateFunction (quantiles (0.75, 0.9, 0.95, 0.99), UInt64) CODEC (ZSTD (1)) +) ENGINE = SummingMergeTree PRIMARY KEY (target) +ORDER BY + (target, timestamp) TTL timestamp + INTERVAL 24 HOUR AS +SELECT target, - toStartOfMinute(timestamp) AS timestamp, + toStartOfMinute (timestamp) AS timestamp, count() AS total, sum(ok) AS total_ok, - avgState(duration) AS duration_avg, - quantilesState(0.75, 0.9, 0.95, 0.99)(duration) AS duration_quantiles -FROM default.operations -GROUP BY target, timestamp + avgState (duration) AS duration_avg, + quantilesState (0.75, 0.9, 0.95, 0.99) (duration) AS duration_quantiles +FROM + DEFAULT.operations +GROUP BY + target, + timestamp ``` -This collapses the 5,000 rows/minute down to **1 row per (target, minute)**. The same 30-minute alert query would scan ~60 rows instead of ~300,000. +This collapses the 5,000 rows/minute down to **1 row per (target, minute)**. The same 30-minute +alert query would scan ~60 rows instead of ~300,000. ### Tradeoffs **Benefits:** + - Dramatically fewer rows to scan for target-wide alerts (the common case) - Simpler, faster merges — no hash/client dimensions to aggregate across - Smaller on-disk footprint for this view **Costs:** -- Additional write amplification — every INSERT into `operations` triggers one more MV materialization -- Alerts with operation or client filters can't use this view — they still need `operations_minutely` to filter by `hash` or `client_name`/`client_version` + +- Additional write amplification — every INSERT into `operations` triggers one more MV + materialization +- Alerts with operation or client filters can't use this view — they still need + `operations_minutely` to filter by `hash` or `client_name`/`client_version` - One more table to maintain and migrate ### Recommendation -For V1, we could start with the existing `operations_minutely` table and measure actual query performance. The primary key starts with `target`, so ClickHouse can efficiently skip irrelevant data even without a dedicated view. If we observe query latency issues at scale, we add the specialized MV as an optimization. +For V1, we could start with the existing `operations_minutely` table and measure actual query +performance. The primary key starts with `target`, so ClickHouse can efficiently skip irrelevant +data even without a dedicated view. If we observe query latency issues at scale, we add the +specialized MV as an optimization. -Alternatively, if we expect many target-wide alerts (likely the majority), adding the MV upfront avoids a future ClickHouse migration. +Alternatively, if we expect many target-wide alerts (likely the majority), adding the MV upfront +avoids a future ClickHouse migration. **Should we add a target-level MV now, or start with existing tables and optimize later?** @@ -587,41 +754,52 @@ Alternatively, if we expect many target-wide alerts (likely the majority), addin ### Evaluation architecture -A **single cron job** runs every minute and evaluates **all** enabled metric alerts. It does not create one job per alert. The process: +A **single cron job** runs every minute and evaluates **all** enabled metric alerts. It does not +create one job per alert. The process: 1. One PostgreSQL query fetches all enabled alerts (joined with channels, targets, orgs) -2. Alerts are grouped by `(target_id, time_window_minutes, filter)` — alerts sharing the same group are served by a **single ClickHouse query** -3. Each ClickHouse query returns both time windows and all metrics (latency percentiles, totals, ok counts) in one result set, serving multiple alert types simultaneously +2. Alerts are grouped by `(target_id, time_window_minutes, filter)` — alerts sharing the same group + are served by a **single ClickHouse query** +3. Each ClickHouse query returns both time windows and all metrics (latency percentiles, totals, ok + counts) in one result set, serving multiple alert types simultaneously ### How it scales -Query count scales with **unique groups**, not alert count. Three alerts on the same target (e.g., latency + errors + traffic with the same window and no filter) cost exactly one ClickHouse query. +Query count scales with **unique groups**, not alert count. Three alerts on the same target (e.g., +latency + errors + traffic with the same window and no filter) cost exactly one ClickHouse query. | Scenario | Configured alerts | Unique groups | CH queries/tick | Est. time (5 concurrent) | -|----------|-------------------|---------------|-----------------|--------------------------| -| Small | 10 | ~5 | 5 | ~10ms | -| Medium | 100 | ~30 | 30 | ~60ms | -| Large | 1,000 | ~150 | 150 | ~300ms | +| -------- | ----------------- | ------------- | --------------- | ------------------------ | +| Small | 10 | ~5 | 5 | ~10ms | +| Medium | 100 | ~30 | 30 | ~60ms | +| Large | 1,000 | ~150 | 150 | ~300ms | -Even at 1,000 alerts, evaluation completes in well under a second. The practical ceiling is the ClickHouse connection pool (32 sockets) and the 60-second task timeout. +Even at 1,000 alerts, evaluation completes in well under a second. The practical ceiling is the +ClickHouse connection pool (32 sockets) and the 60-second task timeout. ### Safeguards -- **Task timeout**: 60 seconds. Graphile-Worker deduplicates cron tasks, so an overrunning evaluation won't spawn a second concurrent instance. -- **Query timeout**: 10 seconds per ClickHouse query (matching existing timeouts in `operations-reader.ts`). -- **Bounded concurrency**: ClickHouse queries execute with `p-queue` concurrency of 5 to avoid saturating the connection pool. +- **Task timeout**: 60 seconds. Graphile-Worker deduplicates cron tasks, so an overrunning + evaluation won't spawn a second concurrent instance. +- **Query timeout**: 10 seconds per ClickHouse query (matching existing timeouts in + `operations-reader.ts`). +- **Bounded concurrency**: ClickHouse queries execute with `p-queue` concurrency of 5 to avoid + saturating the connection pool. --- ## Verification ### Phase 1 + 1. Run migration, verify `alert_channel_type` enum has `EMAIL` and `email_addresses` column exists 2. Create an EMAIL channel via GraphQL playground, verify it persists -3. Create a schema-change alert using the EMAIL channel, publish a schema change, verify email is sent +3. Create a schema-change alert using the EMAIL channel, publish a schema change, verify email is + sent 4. Verify frontend form shows email option and validates correctly ### Phase 2 + 1. Run migration, verify `metric_alerts` table created 2. CRUD metric alerts via GraphQL playground 3. Trigger `evaluateMetricAlerts` task manually, verify ClickHouse queries and state transitions From 4714cbc7b047f696b04d609d4a83beed21197beb Mon Sep 17 00:00:00 2001 From: Jonathan Brennan Date: Wed, 8 Apr 2026 21:07:16 -0500 Subject: [PATCH 04/18] add additional states to plan: NORMAL, PENDING, FIRING, RECOVERING --- .../alerts-and-notifications-improvements.md | 58 ++++++++++++++----- 1 file changed, 44 insertions(+), 14 deletions(-) rename {packages/web/app/.claude => .claude}/plans/alerts-and-notifications-improvements.md (93%) diff --git a/packages/web/app/.claude/plans/alerts-and-notifications-improvements.md b/.claude/plans/alerts-and-notifications-improvements.md similarity index 93% rename from packages/web/app/.claude/plans/alerts-and-notifications-improvements.md rename to .claude/plans/alerts-and-notifications-improvements.md index 0fa9c4326d..54c7fe13ed 100644 --- a/packages/web/app/.claude/plans/alerts-and-notifications-improvements.md +++ b/.claude/plans/alerts-and-notifications-improvements.md @@ -19,12 +19,16 @@ This proposal covers two workstreams: ### Design decisions made so far - **Resolved notifications**: Send a "resolved" notification when a metric alert transitions from - FIRING back to OK + FIRING through RECOVERING back to NORMAL - **Alert scoping**: Metric alerts can optionally be scoped to a specific insights filter (operation IDs and/or client name+version combinations) - **Multiple email recipients**: Email channels support an array of addresses - **Severity levels**: Alerts carry a user-defined severity label (info, warning, critical) for organizational purposes +- **Four-state alert lifecycle**: Alerts transition through NORMAL → PENDING → FIRING → RECOVERING + → NORMAL. The pending and recovering states require the condition to hold for a configurable + number of consecutive minutes (`confirmation_minutes`) before escalating or resolving, preventing + flapping (rapid normal→firing→normal→firing cycles). All four states are user-facing. --- @@ -269,6 +273,8 @@ CREATE TYPE metric_alert_direction AS ENUM('ABOVE', 'BELOW'); CREATE TYPE metric_alert_severity AS ENUM('INFO', 'WARNING', 'CRITICAL'); +CREATE TYPE metric_alert_state AS ENUM('NORMAL', 'PENDING', 'FIRING', 'RECOVERING'); + -- Alert configuration (what to monitor and how) CREATE TABLE metric_alerts ( id UUID PRIMARY KEY DEFAULT gen_random_uuid (), @@ -287,6 +293,12 @@ CREATE TABLE metric_alerts ( created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), enabled BOOLEAN NOT NULL DEFAULT TRUE, last_evaluated_at TIMESTAMPTZ, + -- Alert state machine + state metric_alert_state NOT NULL DEFAULT 'NORMAL', + state_changed_at TIMESTAMPTZ, -- when the current state began + -- How many consecutive minutes the condition must hold before + -- PENDING → FIRING or RECOVERING → NORMAL (prevents flapping) + confirmation_minutes INT NOT NULL DEFAULT 5, -- Optional insights filter scoping (stored as JSONB) -- Matches OperationStatsFilterInput: { operationIds?, excludeOperations?, -- clientVersionFilters?: [{clientName, versions?}], excludeClientVersionFilters? } @@ -341,9 +353,9 @@ Alert configuration CRUD: Incident management: - `createIncident(alertId, currentValue, previousValue, thresholdValue)` — called when alert - transitions OK → FIRING -- `resolveIncident(alertId)` — sets `resolved_at` on the open incident when alert transitions FIRING - → OK + transitions PENDING → FIRING +- `resolveIncident(alertId)` — sets `resolved_at` on the open incident when alert transitions + RECOVERING → NORMAL - `getOpenIncident(alertId)` — find currently firing incident (where `resolved_at IS NULL`) - `getIncidentHistory(alertId, limit, offset)` — paginated history for the UI @@ -377,6 +389,12 @@ enum MetricAlertSeverity { WARNING CRITICAL } +enum MetricAlertState { + NORMAL + PENDING + FIRING + RECOVERING +} type MetricAlert { id: ID! @@ -390,15 +408,13 @@ type MetricAlert { thresholdValue: Float! direction: MetricAlertDirection! severity: MetricAlertSeverity! + state: MetricAlertState! + confirmationMinutes: Int! enabled: Boolean! lastEvaluatedAt: DateTime createdAt: DateTime! filter: OperationStatsFilterInput """ - Whether this alert is currently firing (has an open incident) - """ - isFiring: Boolean! - """ The currently open incident, if any """ currentIncident: MetricAlertIncident @@ -467,12 +483,26 @@ available. This gives on-call engineers a worst-case ~2 minute detection time. 3. Query ClickHouse for current window and previous window (with 1-minute offset to account for ingestion pipeline latency) 4. Compare metric values against thresholds -5. State transitions (determined by whether an open incident exists for the alert): - - **OK → FIRING**: create a new incident row, send notification, update `last_evaluated_at` - - **FIRING → FIRING**: no notification (prevent spam), update `last_evaluated_at` - - **FIRING → OK**: set `resolved_at` on the open incident, send "resolved" notification, update - `last_evaluated_at` - - **OK → OK**: update `last_evaluated_at` only +5. State machine transitions (using `state` and `state_changed_at` on the alert row): + + **When threshold IS breached:** + - **NORMAL → PENDING**: set state to PENDING, set `state_changed_at` to now. No notification yet. + - **PENDING (held < confirmation_minutes)**: remain PENDING, no action. + - **PENDING (held >= confirmation_minutes) → FIRING**: create incident row, send alert + notification, update state + `state_changed_at`. + - **FIRING → FIRING**: no notification (prevent spam), update `last_evaluated_at`. + - **RECOVERING → FIRING**: condition failed again before recovery confirmed. Set state back to + FIRING, update `state_changed_at`. No notification (already sent). + + **When threshold is NOT breached:** + - **NORMAL → NORMAL**: update `last_evaluated_at` only. + - **PENDING → NORMAL**: false alarm — condition didn't hold long enough. Reset state to NORMAL, + update `state_changed_at`. No notification. + - **FIRING → RECOVERING**: set state to RECOVERING, set `state_changed_at` to now. No + notification yet. + - **RECOVERING (held < confirmation_minutes)**: remain RECOVERING, no action. + - **RECOVERING (held >= confirmation_minutes) → NORMAL**: set `resolved_at` on open incident, + send "resolved" notification, update state + `state_changed_at`. #### ClickHouse Query Design From ae2c487e136b49c3eeec827a438549dbec2c6ea5 Mon Sep 17 00:00:00 2001 From: Jonathan Brennan Date: Mon, 13 Apr 2026 08:59:19 -0500 Subject: [PATCH 05/18] rename metric_alerts to metric_alert_rules --- .../alerts-and-notifications-improvements.md | 167 +++++++++++++----- 1 file changed, 121 insertions(+), 46 deletions(-) diff --git a/.claude/plans/alerts-and-notifications-improvements.md b/.claude/plans/alerts-and-notifications-improvements.md index 54c7fe13ed..e9a0af123e 100644 --- a/.claude/plans/alerts-and-notifications-improvements.md +++ b/.claude/plans/alerts-and-notifications-improvements.md @@ -254,13 +254,13 @@ alerts need substantially different configuration: time windows, metric selector types/values, comparison direction, severity, evaluation state, and optional operation/client filters. -A new `metric_alerts` table keeps both systems clean and independently evolvable. It reuses the +A new `metric_alert_rules` table keeps both systems clean and independently evolvable. It reuses the existing `alert_channels` table (including the new EMAIL type from Phase 1) for notification delivery. ### 2.1 Database Migration -**New file:** `packages/migrations/src/actions/2026.03.27T00-00-01.metric-alerts.ts` +**New file:** `packages/migrations/src/actions/2026.03.27T00-00-01.metric-alert-rules.ts` ```sql CREATE TYPE metric_alert_type AS ENUM('LATENCY', 'ERROR_RATE', 'TRAFFIC'); @@ -276,7 +276,7 @@ CREATE TYPE metric_alert_severity AS ENUM('INFO', 'WARNING', 'CRITICAL'); CREATE TYPE metric_alert_state AS ENUM('NORMAL', 'PENDING', 'FIRING', 'RECOVERING'); -- Alert configuration (what to monitor and how) -CREATE TABLE metric_alerts ( +CREATE TABLE metric_alert_rules ( id UUID PRIMARY KEY DEFAULT gen_random_uuid (), organization_id UUID NOT NULL REFERENCES organizations (id) ON DELETE CASCADE, project_id UUID NOT NULL REFERENCES projects (id) ON DELETE CASCADE, @@ -303,7 +303,7 @@ CREATE TABLE metric_alerts ( -- Matches OperationStatsFilterInput: { operationIds?, excludeOperations?, -- clientVersionFilters?: [{clientName, versions?}], excludeClientVersionFilters? } FILTER JSONB, - CONSTRAINT metric_alerts_metric_required CHECK ( + CONSTRAINT metric_alert_rules_metric_required CHECK ( ( type = 'LATENCY' AND metric IS NOT NULL @@ -315,14 +315,14 @@ CREATE TABLE metric_alerts ( ) ); -CREATE INDEX idx_metric_alerts_enabled ON metric_alerts (enabled) +CREATE INDEX idx_metric_alert_rules_enabled ON metric_alert_rules (enabled) WHERE enabled = TRUE; -- Alert incident history (each time an alert fires and resolves) CREATE TABLE metric_alert_incidents ( id UUID PRIMARY KEY DEFAULT gen_random_uuid (), - metric_alert_id UUID NOT NULL REFERENCES metric_alerts (id) ON DELETE CASCADE, + metric_alert_rule_id UUID NOT NULL REFERENCES metric_alert_rules (id) ON DELETE CASCADE, started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), resolved_at TIMESTAMPTZ, -- NULL while still firing current_value DOUBLE PRECISION NOT NULL, @@ -330,85 +330,115 @@ CREATE TABLE metric_alert_incidents ( threshold_value DOUBLE PRECISION NOT NULL -- snapshot of threshold at time of incident ); -CREATE INDEX idx_metric_alert_incidents_alert ON metric_alert_incidents (metric_alert_id); +CREATE INDEX idx_metric_alert_incidents_alert ON metric_alert_incidents (metric_alert_rule_id); -CREATE INDEX idx_metric_alert_incidents_open ON metric_alert_incidents (metric_alert_id) +CREATE INDEX idx_metric_alert_incidents_open ON metric_alert_incidents (metric_alert_rule_id) WHERE resolved_at IS NULL; + +-- State transition log (powers alert history UI: event list, bar chart, state timeline) +-- Retention: 7 days for Hobby/Pro, 30 days for Enterprise (set at insert time via expires_at) +CREATE TABLE metric_alert_state_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid (), + metric_alert_rule_id UUID NOT NULL REFERENCES metric_alert_rules (id) ON DELETE CASCADE, + target_id UUID NOT NULL REFERENCES targets (id) ON DELETE CASCADE, + from_state metric_alert_state NOT NULL, + to_state metric_alert_state NOT NULL, + -- Value snapshot at the time of transition (for historical accuracy even if rule changes) + value DOUBLE PRECISION, -- current window's metric value + previous_value DOUBLE PRECISION, -- previous window's metric value + threshold_value DOUBLE PRECISION, -- snapshot of rule.threshold_value at transition time + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL -- set based on org plan at insert time +); + +CREATE INDEX idx_metric_alert_state_log_rule ON metric_alert_state_log (metric_alert_rule_id, created_at); +CREATE INDEX idx_metric_alert_state_log_target ON metric_alert_state_log (target_id, created_at); +CREATE INDEX idx_metric_alert_state_log_expires ON metric_alert_state_log (expires_at); ``` ### 2.2 Data Access -**New file:** `packages/services/api/src/modules/alerts/providers/metric-alerts-storage.ts` +**New file:** `packages/services/api/src/modules/alerts/providers/metric-alert-rules-storage.ts` Module-level provider with direct `PG_POOL_CONFIG` injection (same modern pattern as Phase 1). Provides: Alert configuration CRUD: -- `addMetricAlert(input)`, `updateMetricAlert(id, fields)`, `deleteMetricAlerts(ids)` -- `getMetricAlerts(projectId)`, `getMetricAlertsByTarget(targetId)` -- `getAllEnabledMetricAlerts()` — used by the workflows evaluation task +- `addMetricAlertRuleRule(input)`, `updateMetricAlertRuleRule(id, fields)`, `deleteMetricAlertRuless(ids)` +- `getMetricAlertRules(projectId)`, `getMetricAlertRulesByTarget(targetId)` +- `getAllEnabledMetricAlertRules()` — used by the workflows evaluation task Incident management: -- `createIncident(alertId, currentValue, previousValue, thresholdValue)` — called when alert +- `createIncident(ruleId, currentValue, previousValue, thresholdValue)` — called when rule transitions PENDING → FIRING -- `resolveIncident(alertId)` — sets `resolved_at` on the open incident when alert transitions +- `resolveIncident(ruleId)` — sets `resolved_at` on the open incident when rule transitions RECOVERING → NORMAL -- `getOpenIncident(alertId)` — find currently firing incident (where `resolved_at IS NULL`) -- `getIncidentHistory(alertId, limit, offset)` — paginated history for the UI +- `getOpenIncident(ruleId)` — find currently firing incident (where `resolved_at IS NULL`) +- `getIncidentHistory(ruleId, limit, offset)` — paginated history for the UI + +State log (powers alert history UI): + +- `logStateTransition(ruleId, targetId, fromState, toState, value, previousValue, thresholdValue, expiresAt)` — + called on every state transition during evaluation. `thresholdValue` is snapshotted from the rule + at transition time so historical events remain accurate even if the rule is later edited. + `expiresAt` is computed from the org's plan: `NOW() + 7 days` for Hobby/Pro, `NOW() + 30 days` + for Enterprise. +- `getStateLog(ruleId, from, to)` — state changes for a single alert rule within a time range +- `getStateLogByTarget(targetId, from, to)` — all state changes across all rules for a target ### 2.3 GraphQL API **File:** `packages/services/api/src/modules/alerts/module.graphql.ts` ```graphql -enum MetricAlertType { +enum MetricAlertRuleType { LATENCY ERROR_RATE TRAFFIC } -enum MetricAlertMetric { +enum MetricAlertRuleMetric { avg p75 p90 p95 p99 } -enum MetricAlertThresholdType { +enum MetricAlertRuleThresholdType { FIXED_VALUE PERCENTAGE_CHANGE } -enum MetricAlertDirection { +enum MetricAlertRuleDirection { ABOVE BELOW } -enum MetricAlertSeverity { +enum MetricAlertRuleSeverity { INFO WARNING CRITICAL } -enum MetricAlertState { +enum MetricAlertRuleState { NORMAL PENDING FIRING RECOVERING } -type MetricAlert { +type MetricAlertRule { id: ID! name: String! - type: MetricAlertType! + type: MetricAlertRuleType! target: Target! channel: AlertChannel! timeWindowMinutes: Int! - metric: MetricAlertMetric - thresholdType: MetricAlertThresholdType! + metric: MetricAlertRuleMetric + thresholdType: MetricAlertRuleThresholdType! thresholdValue: Float! - direction: MetricAlertDirection! - severity: MetricAlertSeverity! - state: MetricAlertState! + direction: MetricAlertRuleDirection! + severity: MetricAlertRuleSeverity! + state: MetricAlertRuleState! confirmationMinutes: Int! enabled: Boolean! lastEvaluatedAt: DateTime @@ -417,14 +447,14 @@ type MetricAlert { """ The currently open incident, if any """ - currentIncident: MetricAlertIncident + currentIncident: MetricAlertRuleIncident """ Past incidents for this alert """ - incidents(first: Int, after: String): MetricAlertIncidentConnection! + incidents(first: Int, after: String): MetricAlertRuleIncidentConnection! } -type MetricAlertIncident { +type MetricAlertRuleIncident { id: ID! startedAt: DateTime! resolvedAt: DateTime @@ -433,16 +463,40 @@ type MetricAlertIncident { thresholdValue: Float! } +type MetricAlertRuleStateChange { + id: ID! + fromState: MetricAlertRuleState! + toState: MetricAlertRuleState! + """Metric value in the current window at transition time""" + value: Float + """Metric value in the previous (comparison) window at transition time""" + previousValue: Float + """Threshold value snapshotted at transition time (survives rule edits)""" + thresholdValue: Float + createdAt: DateTime! + rule: MetricAlertRule! +} + +extend type MetricAlertRule { + """State change history for this rule (powers the state timeline)""" + stateLog(from: DateTime!, to: DateTime!): [MetricAlertRuleStateChange!]! +} + +extend type Target { + """State changes across all alert rules for this target (powers the alert events chart + list)""" + metricAlertRuleStateLog(from: DateTime!, to: DateTime!): [MetricAlertRuleStateChange!]! +} + extend type Project { - metricAlerts: [MetricAlert!] + metricAlertRules: [MetricAlertRule!] } -# Mutations: addMetricAlert, updateMetricAlert, deleteMetricAlerts +# Mutations: addMetricAlertRuleRule, updateMetricAlertRuleRule, deleteMetricAlertRuless # (standard ok/error result pattern) ``` -**New resolver files:** `MetricAlert.ts`, `Mutation/addMetricAlert.ts`, -`Mutation/updateMetricAlert.ts`, `Mutation/deleteMetricAlerts.ts` +**New resolver files:** `MetricAlertRule.ts`, `Mutation/addMetricAlertRuleRule.ts`, +`Mutation/updateMetricAlertRuleRule.ts`, `Mutation/deleteMetricAlertRuless.ts` Uses `alert:modify` permission. @@ -469,9 +523,11 @@ HTTP client so it can query operations metrics. #### Evaluation Task -**New file:** `packages/services/workflows/src/tasks/evaluate-metric-alerts.ts` +**New file:** `packages/services/workflows/src/tasks/evaluate-metric-alert-rules.ts` -Cron: `* * * * * evaluateMetricAlerts` (every minute) +Cron: +- `* * * * * evaluateMetricAlertRules` (every minute) +- `0 4 * * * purgeExpiredAlertStateLog` (daily at 4:00 AM — deletes rows where `expires_at < NOW()`) The ingestion pipeline (API buffer → Kafka → ClickHouse async insert) has a worst-case latency of ~31 seconds, so by the time each 1-minute cron tick fires, the previous minute's data is reliably @@ -619,7 +675,24 @@ Webhook payload: } ``` -### 2.6 Deployment +### 2.6 Alert State Log Retention + +Alert state log retention is plan-gated, following the same pattern as operations data retention +(see `packages/services/api/src/modules/commerce/constants.ts`): + +| Plan | State log retention | +|------------|---------------------| +| Hobby | 7 days | +| Pro | 7 days | +| Enterprise | 30 days | + +When logging a state transition, the evaluation task looks up the organization's plan and sets +`expires_at` accordingly. A daily cron task (`purgeExpiredAlertStateLog`) deletes expired rows. + +**File:** `packages/services/api/src/modules/commerce/constants.ts` — add +`alertStateLogRetentionDays` to each plan's limits. + +### 2.7 Deployment **File:** `deployment/services/workflows.ts` — add ClickHouse env vars to the workflows service deployment config. @@ -628,17 +701,19 @@ deployment config. | File | Change | | ----------------------------------------------------------------------------- | ------------------------------------ | -| `packages/migrations/src/actions/2026.03.27T00-00-01.metric-alerts.ts` | **New** — migration | -| `packages/services/api/src/modules/alerts/providers/metric-alerts-storage.ts` | **New** — module-level CRUD provider | -| `packages/services/api/src/modules/alerts/module.graphql.ts` | Add MetricAlert types/mutations | +| `packages/migrations/src/actions/2026.03.27T00-00-01.metric-alert-rules.ts` | **New** — migration | +| `packages/services/api/src/modules/alerts/providers/metric-alert-rules-storage.ts` | **New** — module-level CRUD provider | +| `packages/services/api/src/modules/alerts/module.graphql.ts` | Add MetricAlertRule types/mutations | | `packages/services/api/src/modules/alerts/resolvers/` | New resolver files | | `packages/services/api/src/modules/alerts/providers/alerts-manager.ts` | Add metric alert methods | | `packages/services/workflows/src/environment.ts` | Add ClickHouse env vars | | `packages/services/workflows/src/context.ts` | Add clickhouse to Context | | `packages/services/workflows/src/lib/clickhouse-client.ts` | **New** — lightweight CH client | -| `packages/services/workflows/src/tasks/evaluate-metric-alerts.ts` | **New** — evaluation cron task | +| `packages/services/workflows/src/tasks/evaluate-metric-alert-rules.ts` | **New** — evaluation cron task | | `packages/services/workflows/src/lib/metric-alert-notifier.ts` | **New** — notification sender | -| `packages/services/workflows/src/index.ts` | Register task + crontab | +| `packages/services/workflows/src/tasks/purge-expired-alert-state-log.ts` | **New** — daily purge task | +| `packages/services/workflows/src/index.ts` | Register tasks + crontab | +| `packages/services/api/src/modules/commerce/constants.ts` | Add alertStateLogRetentionDays | | `deployment/services/workflows.ts` | ClickHouse env vars | --- @@ -830,8 +905,8 @@ ClickHouse connection pool (32 sockets) and the 60-second task timeout. ### Phase 2 -1. Run migration, verify `metric_alerts` table created +1. Run migration, verify `metric_alert_rules` table created 2. CRUD metric alerts via GraphQL playground -3. Trigger `evaluateMetricAlerts` task manually, verify ClickHouse queries and state transitions +3. Trigger `evaluateMetricAlertRules` task manually, verify ClickHouse queries and state transitions 4. Create a webhook metric alert, simulate threshold breach, verify webhook receives payload 5. Add integration test in `integration-tests/tests/api/project/alerts.spec.ts` From 5211d5e00431d3e6b854ab1b958b9c44a36c8fce Mon Sep 17 00:00:00 2001 From: Jonathan Brennan Date: Wed, 15 Apr 2026 19:22:34 -0500 Subject: [PATCH 06/18] shuffle email alert channel work, review figma screens --- .../alerts-and-notifications-improvements.md | 711 +++++++++++------- 1 file changed, 440 insertions(+), 271 deletions(-) diff --git a/.claude/plans/alerts-and-notifications-improvements.md b/.claude/plans/alerts-and-notifications-improvements.md index e9a0af123e..47cb0dfdb2 100644 --- a/.claude/plans/alerts-and-notifications-improvements.md +++ b/.claude/plans/alerts-and-notifications-improvements.md @@ -11,10 +11,11 @@ We also lack email as a notification channel, which is table stakes for an alert This proposal covers two workstreams: -1. **Email alert channel** — Add EMAIL as a new notification channel type, benefiting both existing - schema-change alerts and the new metric alerts -2. **Metric-based alerts** — Add configurable alerts for latency, error rate, and traffic with +1. **Metric-based alerts** — Add configurable alerts for latency, error rate, and traffic with periodic evaluation against ClickHouse data +2. **Email alert channel** — Add EMAIL as a new notification channel type, benefiting both existing + schema-change alerts and the new metric alerts (deferred until after Phase 1 ships; design + decisions around team-member vs. group-email handling are still being finalized) ### Design decisions made so far @@ -32,219 +33,56 @@ This proposal covers two workstreams: --- -## Phase 1: Email Alert Channel - -Follows the exact same pattern used when MS Teams was added -(`2024.06.11T10-10-00.ms-teams-webhook.ts`). - -### 1.1 Database Migration - -**New file:** `packages/migrations/src/actions/2026.03.27T00-00-00.email-alert-channel.ts` - -```sql -ALTER TYPE alert_channel_type -ADD VALUE 'EMAIL'; - --- Array of email addresses to support multiple recipients per channel -ALTER TABLE alert_channels -ADD COLUMN email_addresses TEXT []; -``` - -**Important:** This migration must use `noTransaction: true` because PostgreSQL does not allow -`ALTER TYPE ... ADD VALUE` inside a transaction block. The migration runner (`pg-migrator.ts`) wraps -migrations in transactions by default — the `noTransaction` flag opts out. There are 18+ existing -migrations using this pattern (e.g., index creation with `CREATE INDEX CONCURRENTLY`). Note: the -existing MS Teams migration (`2024.06.11T10-10-00.ms-teams-webhook.ts`) is missing this flag, which -is a latent bug. - -```typescript -export default { - name: '2026.03.27T00-00-00.email-alert-channel.ts', - noTransaction: true, - run: ({ sql }) => [ - { - name: 'Add EMAIL to alert_channel_type', - query: sql`ALTER TYPE alert_channel_type ADD VALUE 'EMAIL'` - }, - { - name: 'Add email_addresses column', - query: sql`ALTER TABLE alert_channels ADD COLUMN email_addresses TEXT[]` - } - ] -} satisfies MigrationExecutor -``` - -Register in `packages/migrations/src/run-pg-migrations.ts`. - -### 1.2 Data Access - -The legacy storage service (`packages/services/storage/src/index.ts`) will not be extended. Instead, -following the modern pattern used by recent modules (app-deployments, schema-proposals, -saved-filters), we create a module-level provider that injects `PG_POOL_CONFIG` directly. - -**New file:** `packages/services/api/src/modules/alerts/providers/alert-channels-storage.ts` - -```typescript -@Injectable({ scope: Scope.Operation }) -export class AlertChannelsStorage { - constructor(@Inject(PG_POOL_CONFIG) private pool: DatabasePool) {} - - async addAlertChannel(input: { ... emailAddresses?: string[] | null }) { ... } - async getAlertChannels(projectId: string) { ... } - async deleteAlertChannels(projectId: string, channelIds: string[]) { ... } -} -``` - -This provider takes over alert channel CRUD from the legacy storage module. Existing callers in -`AlertsManager` are updated to use this new provider instead. - -**File:** `packages/services/api/src/shared/entities.ts` — add `emailAddresses: string[] | null` to -`AlertChannel` interface. - -### 1.3 GraphQL API - -**File:** `packages/services/api/src/modules/alerts/module.graphql.ts` - -```graphql -# Add EMAIL to existing enum -enum AlertChannelType { SLACK, WEBHOOK, MSTEAMS_WEBHOOK, EMAIL } - -# New implementing type -type AlertEmailChannel implements AlertChannel { - id: ID! - name: String! - type: AlertChannelType! - emails: [String!]! -} - -# New input — accepts multiple recipients -input EmailChannelInput { - emails: [String!]! -} - -# Update AddAlertChannelInput to include email field -input AddAlertChannelInput { - ...existing fields... - email: EmailChannelInput -} -``` - -### 1.4 Resolvers - -**New file:** `packages/services/api/src/modules/alerts/resolvers/AlertEmailChannel.ts` - -```typescript -export const AlertEmailChannel: AlertEmailChannelResolvers = { - __isTypeOf: channel => channel.type === 'EMAIL', - emails: channel => channel.emailAddresses ?? [] -} -``` - -**File:** `packages/services/api/src/modules/alerts/resolvers/Mutation/addAlertChannel.ts` - -- Add Zod validation: - `email: MaybeModel(z.object({ emails: z.array(z.string().email().max(255)).min(1).max(10) }))` -- Pass `emailAddresses: input.email?.emails` to AlertsManager - -### 1.5 AlertsManager - -**File:** `packages/services/api/src/modules/alerts/providers/alerts-manager.ts` - -- Update `addChannel()` input to accept `emailAddresses?: string[] | null` -- Pass through to storage -- Update `triggerChannelConfirmation()` to handle EMAIL type -- Update `triggerSchemaChangeNotifications()` to dispatch via email adapter - -### 1.6 Email Communication Adapter - -**New file:** `packages/services/api/src/modules/alerts/providers/adapters/email.ts` - -Implements `CommunicationAdapter` interface. Uses `TaskScheduler` to schedule an email task in the -workflows service (same pattern as `WebhookCommunicationAdapter` which schedules -`SchemaChangeNotificationTask`). - -```typescript -@Injectable() -export class EmailCommunicationAdapter implements CommunicationAdapter { - constructor( - private taskScheduler: TaskScheduler, - private logger: Logger - ) {} - - async sendSchemaChangeNotification(input: SchemaChangeNotificationInput) { - await this.taskScheduler.scheduleTask(AlertEmailNotificationTask, { - recipients: input.channel.emailAddresses ?? [], - event: { - /* schema change details */ - } - }) - } - - async sendChannelConfirmation(input: ChannelConfirmationInput) { - await this.taskScheduler.scheduleTask(AlertEmailConfirmationTask, { - recipients: input.channel.emailAddresses ?? [], - event: input.event - }) - } -} -``` - -### 1.7 Email Task + Template (Workflows Service) - -**New file:** `packages/services/workflows/src/tasks/alert-email-notification.ts` - -- Define `AlertEmailNotificationTask` and `AlertEmailConfirmationTask` -- Send emails using `context.email.send()` with MJML templates (same pattern as - `email-verification.ts`) - -**New file:** `packages/services/workflows/src/lib/emails/templates/alert-notification.ts` - -- MJML email template for schema change notifications (use existing `email()`, `paragraph()`, - `button()` helpers from `components.ts`) - -**File:** `packages/services/workflows/src/index.ts` — register new task module - -### 1.8 Module Registration - -**File:** `packages/services/api/src/modules/alerts/index.ts` — add `EmailCommunicationAdapter` to -providers - -### 1.9 Frontend - -**File:** `packages/web/app/src/components/project/alerts/create-channel.tsx` - -- Add email addresses field with Zod validation (the existing form uses Yup + Formik, but new form - code should use Zod + react-hook-form to match the current codebase convention) -- Show email input when type === EMAIL -- Pass `email: { emails: values.emailAddresses }` in mutation - -**File:** `packages/web/app/src/components/project/alerts/channels-table.tsx` - -- Handle `AlertEmailChannel` typename to display the email addresses - -### Key files for Phase 1 - -| File | Change | -| -------------------------------------------------------------------------------- | -------------------------------------------------------------- | -| `packages/migrations/src/actions/2026.03.27T00-00-00.email-alert-channel.ts` | **New** — migration | -| `packages/migrations/src/run-pg-migrations.ts` | Register migration | -| `packages/services/api/src/modules/alerts/providers/alert-channels-storage.ts` | **New** — module-level CRUD provider (replaces legacy storage) | -| `packages/services/api/src/shared/entities.ts` | Add emailAddresses to AlertChannel | -| `packages/services/api/src/modules/alerts/module.graphql.ts` | Add AlertEmailChannel type + EmailChannelInput | -| `packages/services/api/src/modules/alerts/resolvers/AlertEmailChannel.ts` | **New** — resolver | -| `packages/services/api/src/modules/alerts/resolvers/Mutation/addAlertChannel.ts` | Add email validation + input | -| `packages/services/api/src/modules/alerts/providers/alerts-manager.ts` | Handle EMAIL in dispatch | -| `packages/services/api/src/modules/alerts/providers/adapters/email.ts` | **New** — adapter | -| `packages/services/api/src/modules/alerts/index.ts` | Register adapter | -| `packages/services/workflows/src/tasks/alert-email-notification.ts` | **New** — email task | -| `packages/services/workflows/src/lib/emails/templates/alert-notification.ts` | **New** — MJML template | -| `packages/services/workflows/src/index.ts` | Register task | -| `packages/web/app/src/components/project/alerts/create-channel.tsx` | Add email form field | -| `packages/web/app/src/components/project/alerts/channels-table.tsx` | Display email channels | +## Pre-flight review (pre-implementation validation) + +The plan was reviewed against the current codebase on 2026-04-15. The following items were +validated and are reflected in the sections below. Anything an implementer should double-check +before their first commit is called out explicitly. + +**Validated as matching current conventions:** + +- Migration file naming (`YYYY.MM.DDTHH-MM-SS.description.ts`) and registration via + `await import()` in `run-pg-migrations.ts`. +- `MigrationExecutor` interface supports `noTransaction: true` — correct for the Phase 2 + `ALTER TYPE ... ADD VALUE` migration. +- `uuid_generate_v4()` is the default-UUID convention (not `gen_random_uuid()`). Adjusted + throughout. +- `saved_filters` table exists (migration `2026.02.07T00-00-00.saved-filters.ts`) with + `id UUID`, `project_id UUID`, `filters JSONB`. FK from `metric_alert_rules.saved_filter_id` + is safe. +- `alert:modify` permission exists at + `packages/services/api/src/modules/auth/lib/authz.ts` (~line 405) and is already used by the + existing `AlertsManager`. Reused for metric alert rules — no new permission needed. +- Mutation result shape (`{ ok, error }` discriminated union) matches + `saved-filters/resolvers/Mutation/createSavedFilter.ts`. +- Module-level storage providers with `@Inject(PG_POOL_CONFIG)` are the modern pattern + (saved-filters, proposals, oidc-integrations all follow it). Legacy + `packages/services/storage/src/index.ts` is intentionally not extended. +- Graphile-Worker cron deduplication is automatic — an overrunning evaluation task will not + spawn a concurrent instance on the next tick. +- `send-webhook.ts` in workflows already uses `got` + retries; reusable for metric-alert + webhook delivery with zero infrastructure changes. +- Email provider abstraction (`context.email.send({ to, subject, body })`) in workflows is + reusable for Phase 2 email delivery. + +**Gaps the implementer must close during Phase 1:** + +1. **Add `@slack/web-api` to `packages/services/workflows/package.json`.** Currently only the + API package has it. Pin to the same version as the API package for parity. +2. **Cross-scope validation in mutation resolvers.** The FKs on `metric_alert_rules` cannot + enforce that all `channelIds` and the `saved_filter_id` belong to the same project as the + rule's target. Explicit validation in `addMetricAlertRule` / `updateMetricAlertRule` is + required. See section 1.3 for details. +3. **Regenerate GraphQL types.** Run `pnpm graphql:generate` from repo root after schema edits. + Generated files appear in `packages/services/api/src/__generated__/types.ts` and each + module's `resolvers.generated.ts` — do not hand-edit. +4. **Add `alertStateLogRetentionDays` to commerce plan constants.** The retention column on + `metric_alert_state_log` uses per-plan values (7d Hobby/Pro, 30d Enterprise) driven from + `packages/services/api/src/modules/commerce/constants.ts`. --- -## Phase 2: Metric-Based Alerts +## Phase 1: Metric-Based Alerts ### Why a new table? @@ -255,12 +93,45 @@ types/values, comparison direction, severity, evaluation state, and optional ope filters. A new `metric_alert_rules` table keeps both systems clean and independently evolvable. It reuses the -existing `alert_channels` table (including the new EMAIL type from Phase 1) for notification -delivery. +existing `alert_channels` table (Slack, Webhook, MS Teams) for notification delivery. EMAIL support +will be added in Phase 2. + +### Conventions to follow (validated against codebase) + +These were verified during a final pre-implementation review against current patterns in the repo. + +- **Migration filename format**: `YYYY.MM.DDTHH-MM-SS.description.ts` (matches latest migrations + like `2026.03.25T00-00-00.access-token-expiration.ts`). +- **Migration registration**: Use the dynamic `await import('./actions/...')` pattern in + `packages/migrations/src/run-pg-migrations.ts` (the convention adopted post-Dec 2024). Static + imports exist in older entries but new migrations should use the async form. +- **UUID defaults**: Use `uuid_generate_v4()` (the existing convention) — not `gen_random_uuid()`. + The uuid extension is enabled in the base schema migration. +- **Timestamp columns**: `created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()` and an + `updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()` are present on most modern tables. Apply this + to `metric_alert_rules` (we already have `created_at`; add `updated_at`). +- **Module-level data access**: Inject `PG_POOL_CONFIG` directly via `@Inject(PG_POOL_CONFIG) + private pool: DatabasePool` in an `@Injectable({ scope: Scope.Operation })` class. Mirror the + shape of `packages/services/api/src/modules/saved-filters/providers/saved-filters-storage.ts`. + Do NOT extend the legacy `packages/services/storage/src/index.ts`. +- **Permissions**: Reuse the existing `alert:modify` action (`packages/services/api/src/modules/auth/lib/authz.ts` + ~line 405) for both reads and writes of metric alert rules, matching how the existing + `AlertsManager` handles channels and schema-change alerts. Do not introduce a new `alert:read` + permission — that would expand authz scope unnecessarily. +- **Mutation result shape**: Use the discriminated `{ ok: { ... } } | { error: { message } }` + pattern as shown in `packages/services/api/src/modules/saved-filters/resolvers/Mutation/createSavedFilter.ts`. +- **Connection pattern**: Mirror `SavedFilterConnection` for `MetricAlertRuleIncidentConnection` + and any other paginated connections. +- **GraphQL codegen**: Run `pnpm graphql:generate` from repo root after schema edits. Generated + resolver types appear under `packages/services/api/src/__generated__/types.ts` and module + `resolvers.generated.ts` files (do NOT hand-edit those). +- **GraphQL mappers**: Add `MetricAlertRuleMapper`, `MetricAlertRuleIncidentMapper`, + `MetricAlertRuleStateChangeMapper` to `packages/services/api/src/modules/alerts/module.graphql.mappers.ts`, + pointing at internal entity types defined in `packages/services/api/src/shared/entities.ts`. -### 2.1 Database Migration +### 1.1 Database Migration -**New file:** `packages/migrations/src/actions/2026.03.27T00-00-01.metric-alert-rules.ts` +**New file:** `packages/migrations/src/actions/2026.04.15T00-00-01.metric-alert-rules.ts` ```sql CREATE TYPE metric_alert_type AS ENUM('LATENCY', 'ERROR_RATE', 'TRAFFIC'); @@ -277,11 +148,10 @@ CREATE TYPE metric_alert_state AS ENUM('NORMAL', 'PENDING', 'FIRING', 'RECOVERIN -- Alert configuration (what to monitor and how) CREATE TABLE metric_alert_rules ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid (), + id UUID PRIMARY KEY DEFAULT uuid_generate_v4 (), organization_id UUID NOT NULL REFERENCES organizations (id) ON DELETE CASCADE, project_id UUID NOT NULL REFERENCES projects (id) ON DELETE CASCADE, target_id UUID NOT NULL REFERENCES targets (id) ON DELETE CASCADE, - alert_channel_id UUID NOT NULL REFERENCES alert_channels (id) ON DELETE CASCADE, type metric_alert_type NOT NULL, time_window_minutes INT NOT NULL DEFAULT 30, metric metric_alert_metric, -- only for LATENCY type @@ -291,18 +161,28 @@ CREATE TABLE metric_alert_rules ( severity metric_alert_severity NOT NULL DEFAULT 'WARNING', name TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), enabled BOOLEAN NOT NULL DEFAULT TRUE, last_evaluated_at TIMESTAMPTZ, + last_triggered_at TIMESTAMPTZ, -- denormalized; updated on PENDING → FIRING transitions -- Alert state machine state metric_alert_state NOT NULL DEFAULT 'NORMAL', - state_changed_at TIMESTAMPTZ, -- when the current state began + state_changed_at TIMESTAMPTZ, -- when the current state began -- How many consecutive minutes the condition must hold before -- PENDING → FIRING or RECOVERING → NORMAL (prevents flapping) confirmation_minutes INT NOT NULL DEFAULT 5, - -- Optional insights filter scoping (stored as JSONB) - -- Matches OperationStatsFilterInput: { operationIds?, excludeOperations?, - -- clientVersionFilters?: [{clientName, versions?}], excludeClientVersionFilters? } - FILTER JSONB, + -- Optional insights filter scoping. References a saved filter by ID; the UI picks a saved + -- filter by name (see packages/services/api/src/modules/saved-filters/). If the saved filter + -- is later edited, the rule automatically uses the updated filter contents at evaluation time. + -- If the saved filter is deleted, the rule becomes unscoped (applies to the whole target). + -- + -- IMPORTANT: saved_filters are project-scoped (saved_filters.project_id), not target-scoped. + -- The addMetricAlertRule / updateMetricAlertRule resolvers MUST validate + -- `savedFilter.projectId === target.projectId` before writing, since the FK alone cannot + -- enforce this cross-column invariant. See the pattern used in + -- packages/services/api/src/modules/saved-filters/providers/saved-filters.provider.ts + -- around the `projectId !== target.projectId` check. + saved_filter_id UUID REFERENCES saved_filters (id) ON DELETE SET NULL, CONSTRAINT metric_alert_rules_metric_required CHECK ( ( type = 'LATENCY' @@ -319,9 +199,19 @@ CREATE INDEX idx_metric_alert_rules_enabled ON metric_alert_rules (enabled) WHERE enabled = TRUE; +-- Many-to-many: each rule can notify multiple channels; each channel can serve multiple rules. +-- Matches the UI where users add multiple destinations per alert (e.g. Slack #alerts + Email). +CREATE TABLE metric_alert_rule_channels ( + metric_alert_rule_id UUID NOT NULL REFERENCES metric_alert_rules (id) ON DELETE CASCADE, + alert_channel_id UUID NOT NULL REFERENCES alert_channels (id) ON DELETE CASCADE, + PRIMARY KEY (metric_alert_rule_id, alert_channel_id) +); + +CREATE INDEX idx_marc_channel ON metric_alert_rule_channels (alert_channel_id); + -- Alert incident history (each time an alert fires and resolves) CREATE TABLE metric_alert_incidents ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid (), + id UUID PRIMARY KEY DEFAULT uuid_generate_v4 (), metric_alert_rule_id UUID NOT NULL REFERENCES metric_alert_rules (id) ON DELETE CASCADE, started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), resolved_at TIMESTAMPTZ, -- NULL while still firing @@ -339,7 +229,7 @@ WHERE -- State transition log (powers alert history UI: event list, bar chart, state timeline) -- Retention: 7 days for Hobby/Pro, 30 days for Enterprise (set at insert time via expires_at) CREATE TABLE metric_alert_state_log ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid (), + id UUID PRIMARY KEY DEFAULT uuid_generate_v4 (), metric_alert_rule_id UUID NOT NULL REFERENCES metric_alert_rules (id) ON DELETE CASCADE, target_id UUID NOT NULL REFERENCES targets (id) ON DELETE CASCADE, from_state metric_alert_state NOT NULL, @@ -357,18 +247,27 @@ CREATE INDEX idx_metric_alert_state_log_target ON metric_alert_state_log (target CREATE INDEX idx_metric_alert_state_log_expires ON metric_alert_state_log (expires_at); ``` -### 2.2 Data Access +### 1.2 Data Access **New file:** `packages/services/api/src/modules/alerts/providers/metric-alert-rules-storage.ts` -Module-level provider with direct `PG_POOL_CONFIG` injection (same modern pattern as Phase 1). +Module-level provider with direct `PG_POOL_CONFIG` injection (the modern pattern used by recent +modules like app-deployments, schema-proposals, saved-filters — not the legacy storage service). Provides: Alert configuration CRUD: -- `addMetricAlertRuleRule(input)`, `updateMetricAlertRuleRule(id, fields)`, `deleteMetricAlertRuless(ids)` +- `addMetricAlertRule(input)`, `updateMetricAlertRule(id, fields)`, `deleteMetricAlertRules(ids)` - `getMetricAlertRules(projectId)`, `getMetricAlertRulesByTarget(targetId)` -- `getAllEnabledMetricAlertRules()` — used by the workflows evaluation task +- `getAllEnabledMetricAlertRules()` — used by the workflows evaluation task; returns rules joined + with their channels (via `metric_alert_rule_channels`), saved filter contents (via + `saved_filters.id`), target, project, organization + +Channel management (multi-destination): + +- `setRuleChannels(ruleId, channelIds[])` — replaces the full set of channels attached to a rule + (called from add/update mutations) +- `getRuleChannels(ruleId)` — all channels attached to a rule Incident management: @@ -389,7 +288,7 @@ State log (powers alert history UI): - `getStateLog(ruleId, from, to)` — state changes for a single alert rule within a time range - `getStateLogByTarget(targetId, from, to)` — all state changes across all rules for a target -### 2.3 GraphQL API +### 1.3 GraphQL API **File:** `packages/services/api/src/modules/alerts/module.graphql.ts` @@ -431,7 +330,11 @@ type MetricAlertRule { name: String! type: MetricAlertRuleType! target: Target! - channel: AlertChannel! + """ + Destinations that receive notifications when this rule fires or resolves. + Backed by the metric_alert_rule_channels join table. + """ + channels: [AlertChannel!]! timeWindowMinutes: Int! metric: MetricAlertRuleMetric thresholdType: MetricAlertRuleThresholdType! @@ -442,8 +345,18 @@ type MetricAlertRule { confirmationMinutes: Int! enabled: Boolean! lastEvaluatedAt: DateTime + """Most recent time this rule transitioned PENDING → FIRING (null if never fired)""" + lastTriggeredAt: DateTime createdAt: DateTime! - filter: OperationStatsFilterInput + """ + The saved filter that scopes this rule (null = applies to the whole target). + """ + savedFilter: SavedFilter + """ + Count of state transitions logged for this rule in the given time range. + Powers the "Events" column on the alert rules list. + """ + eventCount(from: DateTime!, to: DateTime!): Int! """ The currently open incident, if any """ @@ -491,16 +404,40 @@ extend type Project { metricAlertRules: [MetricAlertRule!] } -# Mutations: addMetricAlertRuleRule, updateMetricAlertRuleRule, deleteMetricAlertRuless -# (standard ok/error result pattern) +# Mutations (standard ok/error result pattern): +# - addMetricAlertRule(input: AddMetricAlertRuleInput!) +# - updateMetricAlertRule(input: UpdateMetricAlertRuleInput!) +# - deleteMetricAlertRules(input: DeleteMetricAlertRulesInput!) +# +# AddMetricAlertRuleInput / UpdateMetricAlertRuleInput include: +# channelIds: [ID!]! # attach one or more channels (multi-destination) +# savedFilterId: ID # optional FK to a saved filter +# confirmationMinutes: Int # defaults to 5; UI defaults to 0 for long windows ``` -**New resolver files:** `MetricAlertRule.ts`, `Mutation/addMetricAlertRuleRule.ts`, -`Mutation/updateMetricAlertRuleRule.ts`, `Mutation/deleteMetricAlertRuless.ts` +**New resolver files:** `MetricAlertRule.ts`, `MetricAlertRuleIncident.ts`, +`MetricAlertRuleIncidentConnection.ts`, `MetricAlertRuleStateChange.ts`, +`Mutation/addMetricAlertRule.ts`, `Mutation/updateMetricAlertRule.ts`, +`Mutation/deleteMetricAlertRules.ts`. + +Uses `alert:modify` permission for both reads and writes (no separate `alert:read` exists; this +matches how the existing `AlertsManager` handles schema-change alerts). + +**Cross-scope validation in mutations**: The `addMetricAlertRule` / `updateMetricAlertRule` +resolvers must validate — *before* writing — that: + +1. All provided `channelIds` belong to the same project as `targetId`. Each `alert_channels` row + has a `project_id` column; join and reject with an error if any channel's project doesn't + match. +2. If `savedFilterId` is provided, `savedFilter.projectId === target.projectId`. The FK to + `saved_filters(id)` cannot enforce this cross-column invariant on its own. Follow the + validation pattern already in use at + `packages/services/api/src/modules/saved-filters/providers/saved-filters.provider.ts`. -Uses `alert:modify` permission. +Both failures should surface as structured errors via the `{ error: { message } }` result branch, +not thrown exceptions. -### 2.4 Evaluation Engine (Workflows Service) +### 1.4 Evaluation Engine (Workflows Service) #### Why the workflows service? @@ -517,8 +454,10 @@ HTTP client so it can query operations metrics. - `packages/services/workflows/src/environment.ts` — add `CLICKHOUSE_HOST`, `CLICKHOUSE_PORT`, `CLICKHOUSE_USERNAME`, `CLICKHOUSE_PASSWORD` - `packages/services/workflows/src/context.ts` — add `clickhouse` to Context -- `packages/services/workflows/src/lib/clickhouse-client.ts` — **new**, simple fetch-based HTTP - client (the API's ClickHouse client is DI-heavy; we just need raw query execution) +- `packages/services/workflows/src/lib/clickhouse-client.ts` — **new**, simple HTTP client built + on `got` (matching the pattern used by `packages/services/workflows/src/lib/webhooks/send-webhook.ts`). + The API's ClickHouse client is DI-heavy and ships with `agentkeepalive`; we just need raw query + execution with retry on 5xx. - `packages/services/workflows/src/index.ts` — instantiate and inject #### Evaluation Task @@ -533,9 +472,12 @@ The ingestion pipeline (API buffer → Kafka → ClickHouse async insert) has a ~31 seconds, so by the time each 1-minute cron tick fires, the previous minute's data is reliably available. This gives on-call engineers a worst-case ~2 minute detection time. -1. Fetch all enabled metric alerts from PostgreSQL (join with `alert_channels`, `targets`, - `projects`, `organizations`) -2. Group by `(target_id, time_window_minutes, filter)` to batch ClickHouse queries +1. Fetch all enabled metric alert rules from PostgreSQL. Single query joins with + `metric_alert_rule_channels` → `alert_channels` (returning an array of channels per rule), + `saved_filters` (via `saved_filter_id`, for filter contents), `targets`, `projects`, + `organizations`. +2. Group by `(target_id, time_window_minutes, saved_filter_id)` to batch ClickHouse queries. + Rules pointing at the same saved filter share a query. 3. Query ClickHouse for current window and previous window (with 1-minute offset to account for ingestion pipeline latency) 4. Compare metric values against thresholds @@ -545,7 +487,8 @@ available. This gives on-call engineers a worst-case ~2 minute detection time. - **NORMAL → PENDING**: set state to PENDING, set `state_changed_at` to now. No notification yet. - **PENDING (held < confirmation_minutes)**: remain PENDING, no action. - **PENDING (held >= confirmation_minutes) → FIRING**: create incident row, send alert - notification, update state + `state_changed_at`. + notification to **all** channels attached via `metric_alert_rule_channels`, update state + + `state_changed_at`, set `last_triggered_at = NOW()`. - **FIRING → FIRING**: no notification (prevent spam), update `last_evaluated_at`. - **RECOVERING → FIRING**: condition failed again before recovery confirmed. Set state back to FIRING, update `state_changed_at`. No notification (already sent). @@ -558,7 +501,8 @@ available. This gives on-call engineers a worst-case ~2 minute detection time. notification yet. - **RECOVERING (held < confirmation_minutes)**: remain RECOVERING, no action. - **RECOVERING (held >= confirmation_minutes) → NORMAL**: set `resolved_at` on open incident, - send "resolved" notification, update state + `state_changed_at`. + send "resolved" notification to **all** channels attached via `metric_alert_rule_channels`, + update state + `state_changed_at`. #### ClickHouse Query Design @@ -631,20 +575,28 @@ From these: - **Current window has data but previous doesn't exist** (e.g., alert was just created): Skip evaluation until both windows have data. -### 2.5 Notifications from Workflows +### 1.5 Notifications from Workflows **New file:** `packages/services/workflows/src/lib/metric-alert-notifier.ts` Sends notifications directly from the workflows service (the API's DI container is not available here): -- **Webhooks**: Use existing `RequestBroker` / `send-webhook.ts` already in the workflows service -- **Slack**: Direct HTTP POST using `@slack/web-api`. The bot token is stored on the `organizations` - table as `slack_token` and can be queried via PostgreSQL, which the workflows service already has - access to. -- **Teams**: Direct HTTP POST to the webhook URL stored on the channel (simple fetch, same pattern - as the API's `TeamsCommunicationAdapter`) -- **Email**: Use `context.email.send()` (from Phase 1 infrastructure) +- **Webhooks**: Reuse `packages/services/workflows/src/lib/webhooks/send-webhook.ts` directly. It + already handles the `RequestBroker` path, retries via `args.helpers.job.attempts`, and uses + `got`. No changes needed to webhook infrastructure. +- **Slack**: Instantiate `new WebClient(token)` from `@slack/web-api` and call + `client.chat.postMessage(...)`. The token is fetched from PostgreSQL: + `SELECT slack_token FROM organizations WHERE id = $1`. Mirror the message formatting from + `packages/services/api/src/modules/alerts/providers/adapters/slack.ts` (color-coded + `MessageAttachment[]`, mrkdwn, severity badges). + + > **Action required**: Add `"@slack/web-api": "7.10.0"` to + > `packages/services/workflows/package.json` dependencies. It's currently only installed in the + > API package. Keep the version aligned with `packages/services/api/package.json`. +- **Teams**: Raw `got.post()` to the channel's `webhook_endpoint` with a MessageCard JSON body. + Mirror the payload shape from the API's `TeamsCommunicationAdapter` (truncated to 27KB). +- **Email**: Use `context.email.send()` (added in Phase 2). Example messages: @@ -668,14 +620,14 @@ Webhook payload: "previousValue": 200, "changePercent": 125, "threshold": { "type": "FIXED_VALUE", "value": 200, "direction": "ABOVE" }, - "filter": { "operationIds": ["abc123"] }, + "filter": { "savedFilterId": "...", "name": "My Filter", "contents": { "operationIds": ["abc123"] } }, "target": { "slug": "..." }, "project": { "slug": "..." }, "organization": { "slug": "..." } } ``` -### 2.6 Alert State Log Retention +### 1.6 Alert State Log Retention Alert state log retention is plan-gated, following the same pattern as operations data retention (see `packages/services/api/src/modules/commerce/constants.ts`): @@ -692,20 +644,21 @@ When logging a state transition, the evaluation task looks up the organization's **File:** `packages/services/api/src/modules/commerce/constants.ts` — add `alertStateLogRetentionDays` to each plan's limits. -### 2.7 Deployment +### 1.7 Deployment **File:** `deployment/services/workflows.ts` — add ClickHouse env vars to the workflows service deployment config. -### Key files for Phase 2 +### Key files for Phase 1 | File | Change | | ----------------------------------------------------------------------------- | ------------------------------------ | -| `packages/migrations/src/actions/2026.03.27T00-00-01.metric-alert-rules.ts` | **New** — migration | -| `packages/services/api/src/modules/alerts/providers/metric-alert-rules-storage.ts` | **New** — module-level CRUD provider | -| `packages/services/api/src/modules/alerts/module.graphql.ts` | Add MetricAlertRule types/mutations | +| `packages/migrations/src/actions/2026.04.15T00-00-01.metric-alert-rules.ts` | **New** — migration (rules, incidents, state log, **rule_channels join table**) | +| `packages/services/api/src/modules/alerts/providers/metric-alert-rules-storage.ts` | **New** — module-level CRUD provider (incl. setRuleChannels) | +| `packages/services/api/src/modules/alerts/module.graphql.ts` | Add MetricAlertRule types/mutations (multi-channel + savedFilter FK + eventCount + lastTriggeredAt) | | `packages/services/api/src/modules/alerts/resolvers/` | New resolver files | | `packages/services/api/src/modules/alerts/providers/alerts-manager.ts` | Add metric alert methods | +| `packages/services/workflows/package.json` | Add `@slack/web-api` dependency | | `packages/services/workflows/src/environment.ts` | Add ClickHouse env vars | | `packages/services/workflows/src/context.ts` | Add clickhouse to Context | | `packages/services/workflows/src/lib/clickhouse-client.ts` | **New** — lightweight CH client | @@ -718,6 +671,222 @@ deployment config. --- +## Phase 2: Email Alert Channel + +> **Status:** Deferred. This phase ships after Phase 1. Open design questions remain around how +> recipients are represented (free-form email strings vs. references to team members vs. a mix), and +> we want to gather feedback from the Phase 1 rollout before committing to a schema. + +Follows the exact same pattern used when MS Teams was added +(`2024.06.11T10-10-00.ms-teams-webhook.ts`). + +### 2.1 Database Migration + +**New file:** `packages/migrations/src/actions/2026.04.15T00-00-00.email-alert-channel.ts` + +```sql +ALTER TYPE alert_channel_type +ADD VALUE 'EMAIL'; + +-- Array of email addresses to support multiple recipients per channel +ALTER TABLE alert_channels +ADD COLUMN email_addresses TEXT []; +``` + +**Important:** This migration must use `noTransaction: true` because PostgreSQL does not allow +`ALTER TYPE ... ADD VALUE` inside a transaction block. The migration runner (`pg-migrator.ts`) wraps +migrations in transactions by default — the `noTransaction` flag opts out. There are 18+ existing +migrations using this pattern (e.g., index creation with `CREATE INDEX CONCURRENTLY`). Note: the +existing MS Teams migration (`2024.06.11T10-10-00.ms-teams-webhook.ts`) is missing this flag, which +is a latent bug. + +```typescript +export default { + name: '2026.04.15T00-00-00.email-alert-channel.ts', + noTransaction: true, + run: ({ sql }) => [ + { + name: 'Add EMAIL to alert_channel_type', + query: sql`ALTER TYPE alert_channel_type ADD VALUE 'EMAIL'` + }, + { + name: 'Add email_addresses column', + query: sql`ALTER TABLE alert_channels ADD COLUMN email_addresses TEXT[]` + } + ] +} satisfies MigrationExecutor +``` + +Register in `packages/migrations/src/run-pg-migrations.ts`. + +### 2.2 Data Access + +The legacy storage service (`packages/services/storage/src/index.ts`) will not be extended. Instead, +following the modern pattern used by recent modules (app-deployments, schema-proposals, +saved-filters), we create a module-level provider that injects `PG_POOL_CONFIG` directly. + +**New file:** `packages/services/api/src/modules/alerts/providers/alert-channels-storage.ts` + +```typescript +@Injectable({ scope: Scope.Operation }) +export class AlertChannelsStorage { + constructor(@Inject(PG_POOL_CONFIG) private pool: DatabasePool) {} + + async addAlertChannel(input: { ... emailAddresses?: string[] | null }) { ... } + async getAlertChannels(projectId: string) { ... } + async deleteAlertChannels(projectId: string, channelIds: string[]) { ... } +} +``` + +This provider takes over alert channel CRUD from the legacy storage module. Existing callers in +`AlertsManager` are updated to use this new provider instead. + +**File:** `packages/services/api/src/shared/entities.ts` — add `emailAddresses: string[] | null` to +`AlertChannel` interface. + +### 2.3 GraphQL API + +**File:** `packages/services/api/src/modules/alerts/module.graphql.ts` + +```graphql +# Add EMAIL to existing enum +enum AlertChannelType { SLACK, WEBHOOK, MSTEAMS_WEBHOOK, EMAIL } + +# New implementing type +type AlertEmailChannel implements AlertChannel { + id: ID! + name: String! + type: AlertChannelType! + emails: [String!]! +} + +# New input — accepts multiple recipients +input EmailChannelInput { + emails: [String!]! +} + +# Update AddAlertChannelInput to include email field +input AddAlertChannelInput { + ...existing fields... + email: EmailChannelInput +} +``` + +### 2.4 Resolvers + +**New file:** `packages/services/api/src/modules/alerts/resolvers/AlertEmailChannel.ts` + +```typescript +export const AlertEmailChannel: AlertEmailChannelResolvers = { + __isTypeOf: channel => channel.type === 'EMAIL', + emails: channel => channel.emailAddresses ?? [] +} +``` + +**File:** `packages/services/api/src/modules/alerts/resolvers/Mutation/addAlertChannel.ts` + +- Add Zod validation: + `email: MaybeModel(z.object({ emails: z.array(z.string().email().max(255)).min(1).max(10) }))` +- Pass `emailAddresses: input.email?.emails` to AlertsManager + +### 2.5 AlertsManager + +**File:** `packages/services/api/src/modules/alerts/providers/alerts-manager.ts` + +- Update `addChannel()` input to accept `emailAddresses?: string[] | null` +- Pass through to storage +- Update `triggerChannelConfirmation()` to handle EMAIL type +- Update `triggerSchemaChangeNotifications()` to dispatch via email adapter + +### 2.6 Email Communication Adapter + +**New file:** `packages/services/api/src/modules/alerts/providers/adapters/email.ts` + +Implements `CommunicationAdapter` interface. Uses `TaskScheduler` to schedule an email task in the +workflows service (same pattern as `WebhookCommunicationAdapter` which schedules +`SchemaChangeNotificationTask`). + +```typescript +@Injectable() +export class EmailCommunicationAdapter implements CommunicationAdapter { + constructor( + private taskScheduler: TaskScheduler, + private logger: Logger + ) {} + + async sendSchemaChangeNotification(input: SchemaChangeNotificationInput) { + await this.taskScheduler.scheduleTask(AlertEmailNotificationTask, { + recipients: input.channel.emailAddresses ?? [], + event: { + /* schema change details */ + } + }) + } + + async sendChannelConfirmation(input: ChannelConfirmationInput) { + await this.taskScheduler.scheduleTask(AlertEmailConfirmationTask, { + recipients: input.channel.emailAddresses ?? [], + event: input.event + }) + } +} +``` + +### 2.7 Email Task + Template (Workflows Service) + +**New file:** `packages/services/workflows/src/tasks/alert-email-notification.ts` + +- Define `AlertEmailNotificationTask` and `AlertEmailConfirmationTask` +- Send emails using `context.email.send()` with MJML templates (same pattern as + `email-verification.ts`) + +**New file:** `packages/services/workflows/src/lib/emails/templates/alert-notification.ts` + +- MJML email template for schema change notifications (use existing `email()`, `paragraph()`, + `button()` helpers from `components.ts`) + +**File:** `packages/services/workflows/src/index.ts` — register new task module + +### 2.8 Module Registration + +**File:** `packages/services/api/src/modules/alerts/index.ts` — add `EmailCommunicationAdapter` to +providers + +### 2.9 Frontend + +**File:** `packages/web/app/src/components/project/alerts/create-channel.tsx` + +- Add email addresses field with Zod validation (the existing form uses Yup + Formik, but new form + code should use Zod + react-hook-form to match the current codebase convention) +- Show email input when type === EMAIL +- Pass `email: { emails: values.emailAddresses }` in mutation + +**File:** `packages/web/app/src/components/project/alerts/channels-table.tsx` + +- Handle `AlertEmailChannel` typename to display the email addresses + +### Key files for Phase 2 + +| File | Change | +| -------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| `packages/migrations/src/actions/2026.04.15T00-00-00.email-alert-channel.ts` | **New** — migration | +| `packages/migrations/src/run-pg-migrations.ts` | Register migration | +| `packages/services/api/src/modules/alerts/providers/alert-channels-storage.ts` | **New** — module-level CRUD provider (replaces legacy storage) | +| `packages/services/api/src/shared/entities.ts` | Add emailAddresses to AlertChannel | +| `packages/services/api/src/modules/alerts/module.graphql.ts` | Add AlertEmailChannel type + EmailChannelInput | +| `packages/services/api/src/modules/alerts/resolvers/AlertEmailChannel.ts` | **New** — resolver | +| `packages/services/api/src/modules/alerts/resolvers/Mutation/addAlertChannel.ts` | Add email validation + input | +| `packages/services/api/src/modules/alerts/providers/alerts-manager.ts` | Handle EMAIL in dispatch | +| `packages/services/api/src/modules/alerts/providers/adapters/email.ts` | **New** — adapter | +| `packages/services/api/src/modules/alerts/index.ts` | Register adapter | +| `packages/services/workflows/src/tasks/alert-email-notification.ts` | **New** — email task | +| `packages/services/workflows/src/lib/emails/templates/alert-notification.ts` | **New** — MJML template | +| `packages/services/workflows/src/index.ts` | Register task | +| `packages/web/app/src/components/project/alerts/create-channel.tsx` | Add email form field | +| `packages/web/app/src/components/project/alerts/channels-table.tsx` | Display email channels | + +--- + ## Open Question: Time Window Sizes and ClickHouse Data Retention The UI mockup shows "every 7d" as a time window option. Supporting larger windows is valuable — for @@ -837,8 +1006,8 @@ alert query would scan ~60 rows instead of ~300,000. - Additional write amplification — every INSERT into `operations` triggers one more MV materialization -- Alerts with operation or client filters can't use this view — they still need - `operations_minutely` to filter by `hash` or `client_name`/`client_version` +- Rules scoped to a saved filter (operation/client filters) can't use this view — they still + need `operations_minutely` to filter by `hash` or `client_name`/`client_version` - One more table to maintain and migrate ### Recommendation @@ -897,16 +1066,16 @@ ClickHouse connection pool (32 sockets) and the 60-second task timeout. ### Phase 1 -1. Run migration, verify `alert_channel_type` enum has `EMAIL` and `email_addresses` column exists -2. Create an EMAIL channel via GraphQL playground, verify it persists -3. Create a schema-change alert using the EMAIL channel, publish a schema change, verify email is - sent -4. Verify frontend form shows email option and validates correctly - -### Phase 2 - 1. Run migration, verify `metric_alert_rules` table created 2. CRUD metric alerts via GraphQL playground 3. Trigger `evaluateMetricAlertRules` task manually, verify ClickHouse queries and state transitions 4. Create a webhook metric alert, simulate threshold breach, verify webhook receives payload 5. Add integration test in `integration-tests/tests/api/project/alerts.spec.ts` + +### Phase 2 + +1. Run migration, verify `alert_channel_type` enum has `EMAIL` and `email_addresses` column exists +2. Create an EMAIL channel via GraphQL playground, verify it persists +3. Create a schema-change alert using the EMAIL channel, publish a schema change, verify email is + sent +4. Verify frontend form shows email option and validates correctly From 7102e9e7adb670df080148dc4ef55c53b4bf55da Mon Sep 17 00:00:00 2001 From: Jonathan Brennan Date: Thu, 16 Apr 2026 18:21:02 -0500 Subject: [PATCH 07/18] add migration --- .../2026.04.15T00-00-01.metric-alert-rules.ts | 83 +++++++++++++++++++ packages/migrations/src/run-pg-migrations.ts | 1 + 2 files changed, 84 insertions(+) create mode 100644 packages/migrations/src/actions/2026.04.15T00-00-01.metric-alert-rules.ts diff --git a/packages/migrations/src/actions/2026.04.15T00-00-01.metric-alert-rules.ts b/packages/migrations/src/actions/2026.04.15T00-00-01.metric-alert-rules.ts new file mode 100644 index 0000000000..1c97d2ab6e --- /dev/null +++ b/packages/migrations/src/actions/2026.04.15T00-00-01.metric-alert-rules.ts @@ -0,0 +1,83 @@ +import { type MigrationExecutor } from '../pg-migrator'; + +export default { + name: '2026.04.15T00-00-01.metric-alert-rules.ts', + run: ({ psql }) => psql` +CREATE TYPE "metric_alert_type" AS ENUM ('LATENCY', 'ERROR_RATE', 'TRAFFIC'); +CREATE TYPE "metric_alert_metric" AS ENUM ('avg', 'p75', 'p90', 'p95', 'p99'); +CREATE TYPE "metric_alert_threshold_type" AS ENUM ('FIXED_VALUE', 'PERCENTAGE_CHANGE'); +CREATE TYPE "metric_alert_direction" AS ENUM ('ABOVE', 'BELOW'); +CREATE TYPE "metric_alert_severity" AS ENUM ('INFO', 'WARNING', 'CRITICAL'); +CREATE TYPE "metric_alert_state" AS ENUM ('NORMAL', 'PENDING', 'FIRING', 'RECOVERING'); + +CREATE TABLE "metric_alert_rules" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "organization_id" uuid NOT NULL REFERENCES "organizations"("id") ON DELETE CASCADE, + "project_id" uuid NOT NULL REFERENCES "projects"("id") ON DELETE CASCADE, + "target_id" uuid NOT NULL REFERENCES "targets"("id") ON DELETE CASCADE, + "type" "metric_alert_type" NOT NULL, + "time_window_minutes" integer NOT NULL DEFAULT 30, + "metric" "metric_alert_metric", + "threshold_type" "metric_alert_threshold_type" NOT NULL, + "threshold_value" double precision NOT NULL, + "direction" "metric_alert_direction" NOT NULL DEFAULT 'ABOVE', + "severity" "metric_alert_severity" NOT NULL DEFAULT 'WARNING', + "name" text NOT NULL, + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now(), + "enabled" boolean NOT NULL DEFAULT true, + "last_evaluated_at" timestamptz, + "last_triggered_at" timestamptz, + "state" "metric_alert_state" NOT NULL DEFAULT 'NORMAL', + "state_changed_at" timestamptz, + "confirmation_minutes" integer NOT NULL DEFAULT 0, + "saved_filter_id" uuid REFERENCES "saved_filters"("id") ON DELETE SET NULL, + PRIMARY KEY ("id"), + CONSTRAINT "metric_alert_rules_metric_required" CHECK ( + ("type" = 'LATENCY' AND "metric" IS NOT NULL) OR ("type" != 'LATENCY' AND "metric" IS NULL) + ) +); + +CREATE INDEX "idx_metric_alert_rules_enabled" ON "metric_alert_rules" ("enabled") WHERE "enabled" = true; + +CREATE TABLE "metric_alert_rule_channels" ( + "metric_alert_rule_id" uuid NOT NULL REFERENCES "metric_alert_rules"("id") ON DELETE CASCADE, + "alert_channel_id" uuid NOT NULL REFERENCES "alert_channels"("id") ON DELETE CASCADE, + PRIMARY KEY ("metric_alert_rule_id", "alert_channel_id") +); + +CREATE INDEX "idx_metric_alert_rule_channels_channel" ON "metric_alert_rule_channels" ("alert_channel_id"); + +CREATE TABLE "metric_alert_incidents" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "metric_alert_rule_id" uuid NOT NULL REFERENCES "metric_alert_rules"("id") ON DELETE CASCADE, + "started_at" timestamptz NOT NULL DEFAULT now(), + "resolved_at" timestamptz, + "current_value" double precision NOT NULL, + "previous_value" double precision, + "threshold_value" double precision NOT NULL, + PRIMARY KEY ("id") +); + +CREATE INDEX "idx_metric_alert_incidents_rule" ON "metric_alert_incidents" ("metric_alert_rule_id"); +CREATE INDEX "idx_metric_alert_incidents_open" ON "metric_alert_incidents" ("metric_alert_rule_id") WHERE "resolved_at" IS NULL; + +CREATE TABLE "metric_alert_state_log" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "metric_alert_rule_id" uuid NOT NULL REFERENCES "metric_alert_rules"("id") ON DELETE CASCADE, + "target_id" uuid NOT NULL REFERENCES "targets"("id") ON DELETE CASCADE, + "from_state" "metric_alert_state" NOT NULL, + "to_state" "metric_alert_state" NOT NULL, + "value" double precision, + "previous_value" double precision, + "threshold_value" double precision, + "created_at" timestamptz NOT NULL DEFAULT now(), + "expires_at" timestamptz NOT NULL, + PRIMARY KEY ("id") +); + +CREATE INDEX "idx_metric_alert_state_log_rule" ON "metric_alert_state_log" ("metric_alert_rule_id", "created_at"); +CREATE INDEX "idx_metric_alert_state_log_target" ON "metric_alert_state_log" ("target_id", "created_at"); +CREATE INDEX "idx_metric_alert_state_log_expires" ON "metric_alert_state_log" ("expires_at"); +`, +} satisfies MigrationExecutor; diff --git a/packages/migrations/src/run-pg-migrations.ts b/packages/migrations/src/run-pg-migrations.ts index 7e8d35806d..ac87094827 100644 --- a/packages/migrations/src/run-pg-migrations.ts +++ b/packages/migrations/src/run-pg-migrations.ts @@ -184,5 +184,6 @@ export const runPGMigrations = async (args: { slonik: PostgresDatabasePool; runT await import('./actions/2026.02.24T00-00-00.proposal-composition'), await import('./actions/2026.02.25T00-00-00.oidc-integration-domains'), await import('./actions/2026.03.25T00-00-00.access-token-expiration'), + await import('./actions/2026.04.15T00-00-01.metric-alert-rules'), ], }); From a5389dd87c89dc6ee643175961c7c658de6c6a45 Mon Sep 17 00:00:00 2001 From: Jonathan Brennan Date: Thu, 16 Apr 2026 18:31:05 -0500 Subject: [PATCH 08/18] prettier --- .../alerts-and-notifications-improvements.md | 214 ++++++++++-------- 1 file changed, 119 insertions(+), 95 deletions(-) diff --git a/.claude/plans/alerts-and-notifications-improvements.md b/.claude/plans/alerts-and-notifications-improvements.md index 47cb0dfdb2..4b55485707 100644 --- a/.claude/plans/alerts-and-notifications-improvements.md +++ b/.claude/plans/alerts-and-notifications-improvements.md @@ -26,56 +26,55 @@ This proposal covers two workstreams: - **Multiple email recipients**: Email channels support an array of addresses - **Severity levels**: Alerts carry a user-defined severity label (info, warning, critical) for organizational purposes -- **Four-state alert lifecycle**: Alerts transition through NORMAL → PENDING → FIRING → RECOVERING - → NORMAL. The pending and recovering states require the condition to hold for a configurable - number of consecutive minutes (`confirmation_minutes`) before escalating or resolving, preventing +- **Four-state alert lifecycle**: Alerts transition through NORMAL → PENDING → FIRING → RECOVERING → + NORMAL. The pending and recovering states require the condition to hold for a configurable number + of consecutive minutes (`confirmation_minutes`) before escalating or resolving, preventing flapping (rapid normal→firing→normal→firing cycles). All four states are user-facing. --- ## Pre-flight review (pre-implementation validation) -The plan was reviewed against the current codebase on 2026-04-15. The following items were -validated and are reflected in the sections below. Anything an implementer should double-check -before their first commit is called out explicitly. +The plan was reviewed against the current codebase on 2026-04-15. The following items were validated +and are reflected in the sections below. Anything an implementer should double-check before their +first commit is called out explicitly. **Validated as matching current conventions:** -- Migration file naming (`YYYY.MM.DDTHH-MM-SS.description.ts`) and registration via - `await import()` in `run-pg-migrations.ts`. +- Migration file naming (`YYYY.MM.DDTHH-MM-SS.description.ts`) and registration via `await import()` + in `run-pg-migrations.ts`. - `MigrationExecutor` interface supports `noTransaction: true` — correct for the Phase 2 `ALTER TYPE ... ADD VALUE` migration. - `uuid_generate_v4()` is the default-UUID convention (not `gen_random_uuid()`). Adjusted throughout. -- `saved_filters` table exists (migration `2026.02.07T00-00-00.saved-filters.ts`) with - `id UUID`, `project_id UUID`, `filters JSONB`. FK from `metric_alert_rules.saved_filter_id` - is safe. -- `alert:modify` permission exists at - `packages/services/api/src/modules/auth/lib/authz.ts` (~line 405) and is already used by the - existing `AlertsManager`. Reused for metric alert rules — no new permission needed. +- `saved_filters` table exists (migration `2026.02.07T00-00-00.saved-filters.ts`) with `id UUID`, + `project_id UUID`, `filters JSONB`. FK from `metric_alert_rules.saved_filter_id` is safe. +- `alert:modify` permission exists at `packages/services/api/src/modules/auth/lib/authz.ts` + (~line 405) and is already used by the existing `AlertsManager`. Reused for metric alert rules — + no new permission needed. - Mutation result shape (`{ ok, error }` discriminated union) matches `saved-filters/resolvers/Mutation/createSavedFilter.ts`. - Module-level storage providers with `@Inject(PG_POOL_CONFIG)` are the modern pattern (saved-filters, proposals, oidc-integrations all follow it). Legacy `packages/services/storage/src/index.ts` is intentionally not extended. -- Graphile-Worker cron deduplication is automatic — an overrunning evaluation task will not - spawn a concurrent instance on the next tick. -- `send-webhook.ts` in workflows already uses `got` + retries; reusable for metric-alert - webhook delivery with zero infrastructure changes. -- Email provider abstraction (`context.email.send({ to, subject, body })`) in workflows is - reusable for Phase 2 email delivery. +- Graphile-Worker cron deduplication is automatic — an overrunning evaluation task will not spawn a + concurrent instance on the next tick. +- `send-webhook.ts` in workflows already uses `got` + retries; reusable for metric-alert webhook + delivery with zero infrastructure changes. +- Email provider abstraction (`context.email.send({ to, subject, body })`) in workflows is reusable + for Phase 2 email delivery. **Gaps the implementer must close during Phase 1:** -1. **Add `@slack/web-api` to `packages/services/workflows/package.json`.** Currently only the - API package has it. Pin to the same version as the API package for parity. -2. **Cross-scope validation in mutation resolvers.** The FKs on `metric_alert_rules` cannot - enforce that all `channelIds` and the `saved_filter_id` belong to the same project as the - rule's target. Explicit validation in `addMetricAlertRule` / `updateMetricAlertRule` is - required. See section 1.3 for details. +1. **Add `@slack/web-api` to `packages/services/workflows/package.json`.** Currently only the API + package has it. Pin to the same version as the API package for parity. +2. **Cross-scope validation in mutation resolvers.** The FKs on `metric_alert_rules` cannot enforce + that all `channelIds` and the `saved_filter_id` belong to the same project as the rule's target. + Explicit validation in `addMetricAlertRule` / `updateMetricAlertRule` is required. See section + 1.3 for details. 3. **Regenerate GraphQL types.** Run `pnpm graphql:generate` from repo root after schema edits. - Generated files appear in `packages/services/api/src/__generated__/types.ts` and each - module's `resolvers.generated.ts` — do not hand-edit. + Generated files appear in `packages/services/api/src/__generated__/types.ts` and each module's + `resolvers.generated.ts` — do not hand-edit. 4. **Add `alertStateLogRetentionDays` to commerce plan constants.** The retention column on `metric_alert_state_log` uses per-plan values (7d Hobby/Pro, 30d Enterprise) driven from `packages/services/api/src/modules/commerce/constants.ts`. @@ -108,26 +107,30 @@ These were verified during a final pre-implementation review against current pat - **UUID defaults**: Use `uuid_generate_v4()` (the existing convention) — not `gen_random_uuid()`. The uuid extension is enabled in the base schema migration. - **Timestamp columns**: `created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()` and an - `updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()` are present on most modern tables. Apply this - to `metric_alert_rules` (we already have `created_at`; add `updated_at`). -- **Module-level data access**: Inject `PG_POOL_CONFIG` directly via `@Inject(PG_POOL_CONFIG) - private pool: DatabasePool` in an `@Injectable({ scope: Scope.Operation })` class. Mirror the - shape of `packages/services/api/src/modules/saved-filters/providers/saved-filters-storage.ts`. - Do NOT extend the legacy `packages/services/storage/src/index.ts`. -- **Permissions**: Reuse the existing `alert:modify` action (`packages/services/api/src/modules/auth/lib/authz.ts` - ~line 405) for both reads and writes of metric alert rules, matching how the existing - `AlertsManager` handles channels and schema-change alerts. Do not introduce a new `alert:read` - permission — that would expand authz scope unnecessarily. + `updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()` are present on most modern tables. Apply this to + `metric_alert_rules` (we already have `created_at`; add `updated_at`). +- **Module-level data access**: Inject `PG_POOL_CONFIG` directly via + `@Inject(PG_POOL_CONFIG) private pool: DatabasePool` in an + `@Injectable({ scope: Scope.Operation })` class. Mirror the shape of + `packages/services/api/src/modules/saved-filters/providers/saved-filters-storage.ts`. Do NOT + extend the legacy `packages/services/storage/src/index.ts`. +- **Permissions**: Reuse the existing `alert:modify` action + (`packages/services/api/src/modules/auth/lib/authz.ts` ~line 405) for both reads and writes of + metric alert rules, matching how the existing `AlertsManager` handles channels and schema-change + alerts. Do not introduce a new `alert:read` permission — that would expand authz scope + unnecessarily. - **Mutation result shape**: Use the discriminated `{ ok: { ... } } | { error: { message } }` - pattern as shown in `packages/services/api/src/modules/saved-filters/resolvers/Mutation/createSavedFilter.ts`. -- **Connection pattern**: Mirror `SavedFilterConnection` for `MetricAlertRuleIncidentConnection` - and any other paginated connections. + pattern as shown in + `packages/services/api/src/modules/saved-filters/resolvers/Mutation/createSavedFilter.ts`. +- **Connection pattern**: Mirror `SavedFilterConnection` for `MetricAlertRuleIncidentConnection` and + any other paginated connections. - **GraphQL codegen**: Run `pnpm graphql:generate` from repo root after schema edits. Generated resolver types appear under `packages/services/api/src/__generated__/types.ts` and module `resolvers.generated.ts` files (do NOT hand-edit those). - **GraphQL mappers**: Add `MetricAlertRuleMapper`, `MetricAlertRuleIncidentMapper`, - `MetricAlertRuleStateChangeMapper` to `packages/services/api/src/modules/alerts/module.graphql.mappers.ts`, - pointing at internal entity types defined in `packages/services/api/src/shared/entities.ts`. + `MetricAlertRuleStateChangeMapper` to + `packages/services/api/src/modules/alerts/module.graphql.mappers.ts`, pointing at internal entity + types defined in `packages/services/api/src/shared/entities.ts`. ### 1.1 Database Migration @@ -164,10 +167,10 @@ CREATE TABLE metric_alert_rules ( updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), enabled BOOLEAN NOT NULL DEFAULT TRUE, last_evaluated_at TIMESTAMPTZ, - last_triggered_at TIMESTAMPTZ, -- denormalized; updated on PENDING → FIRING transitions + last_triggered_at TIMESTAMPTZ, -- denormalized; updated on PENDING → FIRING transitions -- Alert state machine state metric_alert_state NOT NULL DEFAULT 'NORMAL', - state_changed_at TIMESTAMPTZ, -- when the current state began + state_changed_at TIMESTAMPTZ, -- when the current state began -- How many consecutive minutes the condition must hold before -- PENDING → FIRING or RECOVERING → NORMAL (prevents flapping) confirmation_minutes INT NOT NULL DEFAULT 5, @@ -235,15 +238,17 @@ CREATE TABLE metric_alert_state_log ( from_state metric_alert_state NOT NULL, to_state metric_alert_state NOT NULL, -- Value snapshot at the time of transition (for historical accuracy even if rule changes) - value DOUBLE PRECISION, -- current window's metric value - previous_value DOUBLE PRECISION, -- previous window's metric value - threshold_value DOUBLE PRECISION, -- snapshot of rule.threshold_value at transition time + VALUE DOUBLE PRECISION, -- current window's metric value + previous_value DOUBLE PRECISION, -- previous window's metric value + threshold_value DOUBLE PRECISION, -- snapshot of rule.threshold_value at transition time created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - expires_at TIMESTAMPTZ NOT NULL -- set based on org plan at insert time + expires_at TIMESTAMPTZ NOT NULL -- set based on org plan at insert time ); CREATE INDEX idx_metric_alert_state_log_rule ON metric_alert_state_log (metric_alert_rule_id, created_at); + CREATE INDEX idx_metric_alert_state_log_target ON metric_alert_state_log (target_id, created_at); + CREATE INDEX idx_metric_alert_state_log_expires ON metric_alert_state_log (expires_at); ``` @@ -280,11 +285,11 @@ Incident management: State log (powers alert history UI): -- `logStateTransition(ruleId, targetId, fromState, toState, value, previousValue, thresholdValue, expiresAt)` — - called on every state transition during evaluation. `thresholdValue` is snapshotted from the rule - at transition time so historical events remain accurate even if the rule is later edited. - `expiresAt` is computed from the org's plan: `NOW() + 7 days` for Hobby/Pro, `NOW() + 30 days` - for Enterprise. +- `logStateTransition(ruleId, targetId, fromState, toState, value, previousValue, thresholdValue, expiresAt)` + — called on every state transition during evaluation. `thresholdValue` is snapshotted from the + rule at transition time so historical events remain accurate even if the rule is later edited. + `expiresAt` is computed from the org's plan: `NOW() + 7 days` for Hobby/Pro, `NOW() + 30 days` for + Enterprise. - `getStateLog(ruleId, from, to)` — state changes for a single alert rule within a time range - `getStateLogByTarget(targetId, from, to)` — all state changes across all rules for a target @@ -345,7 +350,9 @@ type MetricAlertRule { confirmationMinutes: Int! enabled: Boolean! lastEvaluatedAt: DateTime - """Most recent time this rule transitioned PENDING → FIRING (null if never fired)""" + """ + Most recent time this rule transitioned PENDING → FIRING (null if never fired) + """ lastTriggeredAt: DateTime createdAt: DateTime! """ @@ -380,23 +387,33 @@ type MetricAlertRuleStateChange { id: ID! fromState: MetricAlertRuleState! toState: MetricAlertRuleState! - """Metric value in the current window at transition time""" + """ + Metric value in the current window at transition time + """ value: Float - """Metric value in the previous (comparison) window at transition time""" + """ + Metric value in the previous (comparison) window at transition time + """ previousValue: Float - """Threshold value snapshotted at transition time (survives rule edits)""" + """ + Threshold value snapshotted at transition time (survives rule edits) + """ thresholdValue: Float createdAt: DateTime! rule: MetricAlertRule! } extend type MetricAlertRule { - """State change history for this rule (powers the state timeline)""" + """ + State change history for this rule (powers the state timeline) + """ stateLog(from: DateTime!, to: DateTime!): [MetricAlertRuleStateChange!]! } extend type Target { - """State changes across all alert rules for this target (powers the alert events chart + list)""" + """ + State changes across all alert rules for this target (powers the alert events chart + list) + """ metricAlertRuleStateLog(from: DateTime!, to: DateTime!): [MetricAlertRuleStateChange!]! } @@ -424,14 +441,13 @@ Uses `alert:modify` permission for both reads and writes (no separate `alert:rea matches how the existing `AlertsManager` handles schema-change alerts). **Cross-scope validation in mutations**: The `addMetricAlertRule` / `updateMetricAlertRule` -resolvers must validate — *before* writing — that: +resolvers must validate — _before_ writing — that: -1. All provided `channelIds` belong to the same project as `targetId`. Each `alert_channels` row - has a `project_id` column; join and reject with an error if any channel's project doesn't - match. +1. All provided `channelIds` belong to the same project as `targetId`. Each `alert_channels` row has + a `project_id` column; join and reject with an error if any channel's project doesn't match. 2. If `savedFilterId` is provided, `savedFilter.projectId === target.projectId`. The FK to - `saved_filters(id)` cannot enforce this cross-column invariant on its own. Follow the - validation pattern already in use at + `saved_filters(id)` cannot enforce this cross-column invariant on its own. Follow the validation + pattern already in use at `packages/services/api/src/modules/saved-filters/providers/saved-filters.provider.ts`. Both failures should surface as structured errors via the `{ error: { message } }` result branch, @@ -454,10 +470,10 @@ HTTP client so it can query operations metrics. - `packages/services/workflows/src/environment.ts` — add `CLICKHOUSE_HOST`, `CLICKHOUSE_PORT`, `CLICKHOUSE_USERNAME`, `CLICKHOUSE_PASSWORD` - `packages/services/workflows/src/context.ts` — add `clickhouse` to Context -- `packages/services/workflows/src/lib/clickhouse-client.ts` — **new**, simple HTTP client built - on `got` (matching the pattern used by `packages/services/workflows/src/lib/webhooks/send-webhook.ts`). - The API's ClickHouse client is DI-heavy and ships with `agentkeepalive`; we just need raw query - execution with retry on 5xx. +- `packages/services/workflows/src/lib/clickhouse-client.ts` — **new**, simple HTTP client built on + `got` (matching the pattern used by + `packages/services/workflows/src/lib/webhooks/send-webhook.ts`). The API's ClickHouse client is + DI-heavy and ships with `agentkeepalive`; we just need raw query execution with retry on 5xx. - `packages/services/workflows/src/index.ts` — instantiate and inject #### Evaluation Task @@ -465,6 +481,7 @@ HTTP client so it can query operations metrics. **New file:** `packages/services/workflows/src/tasks/evaluate-metric-alert-rules.ts` Cron: + - `* * * * * evaluateMetricAlertRules` (every minute) - `0 4 * * * purgeExpiredAlertStateLog` (daily at 4:00 AM — deletes rows where `expires_at < NOW()`) @@ -476,14 +493,15 @@ available. This gives on-call engineers a worst-case ~2 minute detection time. `metric_alert_rule_channels` → `alert_channels` (returning an array of channels per rule), `saved_filters` (via `saved_filter_id`, for filter contents), `targets`, `projects`, `organizations`. -2. Group by `(target_id, time_window_minutes, saved_filter_id)` to batch ClickHouse queries. - Rules pointing at the same saved filter share a query. +2. Group by `(target_id, time_window_minutes, saved_filter_id)` to batch ClickHouse queries. Rules + pointing at the same saved filter share a query. 3. Query ClickHouse for current window and previous window (with 1-minute offset to account for ingestion pipeline latency) 4. Compare metric values against thresholds 5. State machine transitions (using `state` and `state_changed_at` on the alert row): **When threshold IS breached:** + - **NORMAL → PENDING**: set state to PENDING, set `state_changed_at` to now. No notification yet. - **PENDING (held < confirmation_minutes)**: remain PENDING, no action. - **PENDING (held >= confirmation_minutes) → FIRING**: create incident row, send alert @@ -494,6 +512,7 @@ available. This gives on-call engineers a worst-case ~2 minute detection time. FIRING, update `state_changed_at`. No notification (already sent). **When threshold is NOT breached:** + - **NORMAL → NORMAL**: update `last_evaluated_at` only. - **PENDING → NORMAL**: false alarm — condition didn't hold long enough. Reset state to NORMAL, update `state_changed_at`. No notification. @@ -583,8 +602,8 @@ Sends notifications directly from the workflows service (the API's DI container here): - **Webhooks**: Reuse `packages/services/workflows/src/lib/webhooks/send-webhook.ts` directly. It - already handles the `RequestBroker` path, retries via `args.helpers.job.attempts`, and uses - `got`. No changes needed to webhook infrastructure. + already handles the `RequestBroker` path, retries via `args.helpers.job.attempts`, and uses `got`. + No changes needed to webhook infrastructure. - **Slack**: Instantiate `new WebClient(token)` from `@slack/web-api` and call `client.chat.postMessage(...)`. The token is fetched from PostgreSQL: `SELECT slack_token FROM organizations WHERE id = $1`. Mirror the message formatting from @@ -594,6 +613,7 @@ here): > **Action required**: Add `"@slack/web-api": "7.10.0"` to > `packages/services/workflows/package.json` dependencies. It's currently only installed in the > API package. Keep the version aligned with `packages/services/api/package.json`. + - **Teams**: Raw `got.post()` to the channel's `webhook_endpoint` with a MessageCard JSON body. Mirror the payload shape from the API's `TeamsCommunicationAdapter` (truncated to 27KB). - **Email**: Use `context.email.send()` (added in Phase 2). @@ -620,7 +640,11 @@ Webhook payload: "previousValue": 200, "changePercent": 125, "threshold": { "type": "FIXED_VALUE", "value": 200, "direction": "ABOVE" }, - "filter": { "savedFilterId": "...", "name": "My Filter", "contents": { "operationIds": ["abc123"] } }, + "filter": { + "savedFilterId": "...", + "name": "My Filter", + "contents": { "operationIds": ["abc123"] } + }, "target": { "slug": "..." }, "project": { "slug": "..." }, "organization": { "slug": "..." } @@ -633,7 +657,7 @@ Alert state log retention is plan-gated, following the same pattern as operation (see `packages/services/api/src/modules/commerce/constants.ts`): | Plan | State log retention | -|------------|---------------------| +| ---------- | ------------------- | | Hobby | 7 days | | Pro | 7 days | | Enterprise | 30 days | @@ -651,23 +675,23 @@ deployment config. ### Key files for Phase 1 -| File | Change | -| ----------------------------------------------------------------------------- | ------------------------------------ | -| `packages/migrations/src/actions/2026.04.15T00-00-01.metric-alert-rules.ts` | **New** — migration (rules, incidents, state log, **rule_channels join table**) | -| `packages/services/api/src/modules/alerts/providers/metric-alert-rules-storage.ts` | **New** — module-level CRUD provider (incl. setRuleChannels) | -| `packages/services/api/src/modules/alerts/module.graphql.ts` | Add MetricAlertRule types/mutations (multi-channel + savedFilter FK + eventCount + lastTriggeredAt) | -| `packages/services/api/src/modules/alerts/resolvers/` | New resolver files | -| `packages/services/api/src/modules/alerts/providers/alerts-manager.ts` | Add metric alert methods | -| `packages/services/workflows/package.json` | Add `@slack/web-api` dependency | -| `packages/services/workflows/src/environment.ts` | Add ClickHouse env vars | -| `packages/services/workflows/src/context.ts` | Add clickhouse to Context | -| `packages/services/workflows/src/lib/clickhouse-client.ts` | **New** — lightweight CH client | -| `packages/services/workflows/src/tasks/evaluate-metric-alert-rules.ts` | **New** — evaluation cron task | -| `packages/services/workflows/src/lib/metric-alert-notifier.ts` | **New** — notification sender | -| `packages/services/workflows/src/tasks/purge-expired-alert-state-log.ts` | **New** — daily purge task | -| `packages/services/workflows/src/index.ts` | Register tasks + crontab | -| `packages/services/api/src/modules/commerce/constants.ts` | Add alertStateLogRetentionDays | -| `deployment/services/workflows.ts` | ClickHouse env vars | +| File | Change | +| ---------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| `packages/migrations/src/actions/2026.04.15T00-00-01.metric-alert-rules.ts` | **New** — migration (rules, incidents, state log, **rule_channels join table**) | +| `packages/services/api/src/modules/alerts/providers/metric-alert-rules-storage.ts` | **New** — module-level CRUD provider (incl. setRuleChannels) | +| `packages/services/api/src/modules/alerts/module.graphql.ts` | Add MetricAlertRule types/mutations (multi-channel + savedFilter FK + eventCount + lastTriggeredAt) | +| `packages/services/api/src/modules/alerts/resolvers/` | New resolver files | +| `packages/services/api/src/modules/alerts/providers/alerts-manager.ts` | Add metric alert methods | +| `packages/services/workflows/package.json` | Add `@slack/web-api` dependency | +| `packages/services/workflows/src/environment.ts` | Add ClickHouse env vars | +| `packages/services/workflows/src/context.ts` | Add clickhouse to Context | +| `packages/services/workflows/src/lib/clickhouse-client.ts` | **New** — lightweight CH client | +| `packages/services/workflows/src/tasks/evaluate-metric-alert-rules.ts` | **New** — evaluation cron task | +| `packages/services/workflows/src/lib/metric-alert-notifier.ts` | **New** — notification sender | +| `packages/services/workflows/src/tasks/purge-expired-alert-state-log.ts` | **New** — daily purge task | +| `packages/services/workflows/src/index.ts` | Register tasks + crontab | +| `packages/services/api/src/modules/commerce/constants.ts` | Add alertStateLogRetentionDays | +| `deployment/services/workflows.ts` | ClickHouse env vars | --- @@ -1006,8 +1030,8 @@ alert query would scan ~60 rows instead of ~300,000. - Additional write amplification — every INSERT into `operations` triggers one more MV materialization -- Rules scoped to a saved filter (operation/client filters) can't use this view — they still - need `operations_minutely` to filter by `hash` or `client_name`/`client_version` +- Rules scoped to a saved filter (operation/client filters) can't use this view — they still need + `operations_minutely` to filter by `hash` or `client_name`/`client_version` - One more table to maintain and migrate ### Recommendation From 0481f39f81c288020465a64d41bcc1c842118c75 Mon Sep 17 00:00:00 2001 From: Jonathan Brennan Date: Thu, 16 Apr 2026 18:32:27 -0500 Subject: [PATCH 09/18] add .claude to prettierignore --- .prettierignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.prettierignore b/.prettierignore index 9cd0745194..a5b6096633 100644 --- a/.prettierignore +++ b/.prettierignore @@ -26,6 +26,7 @@ pnpm-lock.yaml .bob/ .changeset/ +.claude/ CHANGELOG.md # temp volumes From 42e53e74388db4227ffd9fdf643a5c8702a231cb Mon Sep 17 00:00:00 2001 From: Jonathan Brennan Date: Thu, 16 Apr 2026 19:18:56 -0500 Subject: [PATCH 10/18] add metric alert rules entity types and storage provider --- .../services/api/src/modules/alerts/index.ts | 2 + .../providers/metric-alert-rules-storage.ts | 502 ++++++++++++++++++ packages/services/api/src/shared/entities.ts | 54 ++ 3 files changed, 558 insertions(+) create mode 100644 packages/services/api/src/modules/alerts/providers/metric-alert-rules-storage.ts diff --git a/packages/services/api/src/modules/alerts/index.ts b/packages/services/api/src/modules/alerts/index.ts index 471d4890cc..7f1f613a49 100644 --- a/packages/services/api/src/modules/alerts/index.ts +++ b/packages/services/api/src/modules/alerts/index.ts @@ -3,6 +3,7 @@ import { TeamsCommunicationAdapter } from './providers/adapters/msteams'; import { SlackCommunicationAdapter } from './providers/adapters/slack'; import { WebhookCommunicationAdapter } from './providers/adapters/webhook'; import { AlertsManager } from './providers/alerts-manager'; +import { MetricAlertRulesStorage } from './providers/metric-alert-rules-storage'; import { resolvers } from './resolvers.generated'; import typeDefs from './module.graphql'; @@ -13,6 +14,7 @@ export const alertsModule = createModule({ resolvers, providers: [ AlertsManager, + MetricAlertRulesStorage, SlackCommunicationAdapter, WebhookCommunicationAdapter, TeamsCommunicationAdapter, diff --git a/packages/services/api/src/modules/alerts/providers/metric-alert-rules-storage.ts b/packages/services/api/src/modules/alerts/providers/metric-alert-rules-storage.ts new file mode 100644 index 0000000000..c4993c12ca --- /dev/null +++ b/packages/services/api/src/modules/alerts/providers/metric-alert-rules-storage.ts @@ -0,0 +1,502 @@ +import { Injectable, Scope } from 'graphql-modules'; +import * as zod from 'zod'; +import { PostgresDatabasePool, psql } from '@hive/postgres'; +import type { + MetricAlertIncident, + MetricAlertRule, + MetricAlertRuleState, + MetricAlertStateLogEntry, +} from '../../../shared/entities'; + +const MetricAlertRuleModel = zod.object({ + id: zod.string(), + organizationId: zod.string(), + projectId: zod.string(), + targetId: zod.string(), + type: zod.enum(['LATENCY', 'ERROR_RATE', 'TRAFFIC']), + timeWindowMinutes: zod.number(), + metric: zod.enum(['avg', 'p75', 'p90', 'p95', 'p99']).nullable(), + thresholdType: zod.enum(['FIXED_VALUE', 'PERCENTAGE_CHANGE']), + thresholdValue: zod.number(), + direction: zod.enum(['ABOVE', 'BELOW']), + severity: zod.enum(['INFO', 'WARNING', 'CRITICAL']), + name: zod.string(), + createdAt: zod.string(), + updatedAt: zod.string(), + enabled: zod.boolean(), + lastEvaluatedAt: zod.string().nullable(), + lastTriggeredAt: zod.string().nullable(), + state: zod.enum(['NORMAL', 'PENDING', 'FIRING', 'RECOVERING']), + stateChangedAt: zod.string().nullable(), + confirmationMinutes: zod.number(), + savedFilterId: zod.string().nullable(), +}); + +const MetricAlertIncidentModel = zod.object({ + id: zod.string(), + metricAlertRuleId: zod.string(), + startedAt: zod.string(), + resolvedAt: zod.string().nullable(), + currentValue: zod.number(), + previousValue: zod.number().nullable(), + thresholdValue: zod.number(), +}); + +const MetricAlertStateLogModel = zod.object({ + id: zod.string(), + metricAlertRuleId: zod.string(), + targetId: zod.string(), + fromState: zod.enum(['NORMAL', 'PENDING', 'FIRING', 'RECOVERING']), + toState: zod.enum(['NORMAL', 'PENDING', 'FIRING', 'RECOVERING']), + value: zod.number().nullable(), + previousValue: zod.number().nullable(), + thresholdValue: zod.number().nullable(), + createdAt: zod.string(), + expiresAt: zod.string(), +}); + +const METRIC_ALERT_RULE_SELECT = psql` + "id" + , "organization_id" as "organizationId" + , "project_id" as "projectId" + , "target_id" as "targetId" + , "type" + , "time_window_minutes" as "timeWindowMinutes" + , "metric" + , "threshold_type" as "thresholdType" + , "threshold_value" as "thresholdValue" + , "direction" + , "severity" + , "name" + , to_json("created_at") as "createdAt" + , to_json("updated_at") as "updatedAt" + , "enabled" + , to_json("last_evaluated_at") as "lastEvaluatedAt" + , to_json("last_triggered_at") as "lastTriggeredAt" + , "state" + , to_json("state_changed_at") as "stateChangedAt" + , "confirmation_minutes" as "confirmationMinutes" + , "saved_filter_id" as "savedFilterId" +`; + +const METRIC_ALERT_INCIDENT_SELECT = psql` + "id" + , "metric_alert_rule_id" as "metricAlertRuleId" + , to_json("started_at") as "startedAt" + , to_json("resolved_at") as "resolvedAt" + , "current_value" as "currentValue" + , "previous_value" as "previousValue" + , "threshold_value" as "thresholdValue" +`; + +const METRIC_ALERT_STATE_LOG_SELECT = psql` + "id" + , "metric_alert_rule_id" as "metricAlertRuleId" + , "target_id" as "targetId" + , "from_state" as "fromState" + , "to_state" as "toState" + , "value" + , "previous_value" as "previousValue" + , "threshold_value" as "thresholdValue" + , to_json("created_at") as "createdAt" + , to_json("expires_at") as "expiresAt" +`; + +@Injectable({ + scope: Scope.Operation, +}) +export class MetricAlertRulesStorage { + constructor(private pool: PostgresDatabasePool) {} + + // --- Alert Rule CRUD --- + + async getMetricAlertRule(args: { id: string }): Promise { + const result = await this.pool.maybeOne(psql`/* getMetricAlertRule */ + SELECT ${METRIC_ALERT_RULE_SELECT} + FROM "metric_alert_rules" + WHERE "id" = ${args.id} + `); + + if (result === null) { + return null; + } + + return MetricAlertRuleModel.parse(result) as MetricAlertRule; + } + + async getMetricAlertRules(args: { projectId: string }): Promise { + const result = await this.pool.any(psql`/* getMetricAlertRules */ + SELECT ${METRIC_ALERT_RULE_SELECT} + FROM "metric_alert_rules" + WHERE "project_id" = ${args.projectId} + ORDER BY "created_at" DESC + `); + + return result.map(row => MetricAlertRuleModel.parse(row) as MetricAlertRule); + } + + async getMetricAlertRulesByTarget(args: { targetId: string }): Promise { + const result = await this.pool.any(psql`/* getMetricAlertRulesByTarget */ + SELECT ${METRIC_ALERT_RULE_SELECT} + FROM "metric_alert_rules" + WHERE "target_id" = ${args.targetId} + ORDER BY "created_at" DESC + `); + + return result.map(row => MetricAlertRuleModel.parse(row) as MetricAlertRule); + } + + async getAllEnabledMetricAlertRules(): Promise { + const result = await this.pool.any(psql`/* getAllEnabledMetricAlertRules */ + SELECT ${METRIC_ALERT_RULE_SELECT} + FROM "metric_alert_rules" + WHERE "enabled" = true + ORDER BY "id" + `); + + return result.map(row => MetricAlertRuleModel.parse(row) as MetricAlertRule); + } + + async addMetricAlertRule(args: { + organizationId: string; + projectId: string; + targetId: string; + type: MetricAlertRule['type']; + timeWindowMinutes: number; + metric: MetricAlertRule['metric']; + thresholdType: MetricAlertRule['thresholdType']; + thresholdValue: number; + direction: MetricAlertRule['direction']; + severity: MetricAlertRule['severity']; + name: string; + confirmationMinutes: number; + savedFilterId: string | null; + }): Promise { + const result = await this.pool.one(psql`/* addMetricAlertRule */ + INSERT INTO "metric_alert_rules" ( + "organization_id" + , "project_id" + , "target_id" + , "type" + , "time_window_minutes" + , "metric" + , "threshold_type" + , "threshold_value" + , "direction" + , "severity" + , "name" + , "confirmation_minutes" + , "saved_filter_id" + ) + VALUES ( + ${args.organizationId} + , ${args.projectId} + , ${args.targetId} + , ${args.type} + , ${args.timeWindowMinutes} + , ${args.metric} + , ${args.thresholdType} + , ${args.thresholdValue} + , ${args.direction} + , ${args.severity} + , ${args.name} + , ${args.confirmationMinutes} + , ${args.savedFilterId} + ) + RETURNING ${METRIC_ALERT_RULE_SELECT} + `); + + return MetricAlertRuleModel.parse(result) as MetricAlertRule; + } + + async updateMetricAlertRule(args: { + id: string; + type?: MetricAlertRule['type']; + timeWindowMinutes?: number; + metric?: MetricAlertRule['metric']; + thresholdType?: MetricAlertRule['thresholdType']; + thresholdValue?: number; + direction?: MetricAlertRule['direction']; + severity?: MetricAlertRule['severity']; + name?: string; + confirmationMinutes?: number; + savedFilterId?: string | null; + enabled?: boolean; + }): Promise { + const result = await this.pool.maybeOne(psql`/* updateMetricAlertRule */ + UPDATE "metric_alert_rules" + SET + "type" = COALESCE(${args.type ?? null}, "type") + , "time_window_minutes" = COALESCE(${args.timeWindowMinutes ?? null}, "time_window_minutes") + , "metric" = COALESCE(${args.metric ?? null}, "metric") + , "threshold_type" = COALESCE(${args.thresholdType ?? null}, "threshold_type") + , "threshold_value" = COALESCE(${args.thresholdValue ?? null}, "threshold_value") + , "direction" = COALESCE(${args.direction ?? null}, "direction") + , "severity" = COALESCE(${args.severity ?? null}, "severity") + , "name" = COALESCE(${args.name ?? null}, "name") + , "confirmation_minutes" = COALESCE(${args.confirmationMinutes ?? null}, "confirmation_minutes") + , "saved_filter_id" = COALESCE(${args.savedFilterId ?? null}, "saved_filter_id") + , "enabled" = COALESCE(${args.enabled ?? null}, "enabled") + , "updated_at" = NOW() + WHERE + "id" = ${args.id} + RETURNING ${METRIC_ALERT_RULE_SELECT} + `); + + if (result === null) { + return null; + } + + return MetricAlertRuleModel.parse(result) as MetricAlertRule; + } + + async deleteMetricAlertRules(args: { + projectId: string; + ruleIds: string[]; + }): Promise { + const result = await this.pool.any(psql`/* deleteMetricAlertRules */ + DELETE FROM "metric_alert_rules" + WHERE + "project_id" = ${args.projectId} + AND "id" = ANY(${psql.array(args.ruleIds, 'uuid')}) + RETURNING ${METRIC_ALERT_RULE_SELECT} + `); + + return result.map(row => MetricAlertRuleModel.parse(row) as MetricAlertRule); + } + + // --- Rule Channels (many-to-many) --- + + async setRuleChannels(args: { + ruleId: string; + channelIds: string[]; + }): Promise { + await this.pool.transaction(async trx => { + await trx.query(psql` + DELETE FROM "metric_alert_rule_channels" + WHERE "metric_alert_rule_id" = ${args.ruleId} + `); + + if (args.channelIds.length > 0) { + await trx.query(psql` + INSERT INTO "metric_alert_rule_channels" ("metric_alert_rule_id", "alert_channel_id") + SELECT ${args.ruleId}, unnest(${psql.array(args.channelIds, 'uuid')}) + `); + } + }); + } + + async getRuleChannelIds(args: { ruleId: string }): Promise { + const result = await this.pool.anyFirst(psql`/* getRuleChannelIds */ + SELECT "alert_channel_id" + FROM "metric_alert_rule_channels" + WHERE "metric_alert_rule_id" = ${args.ruleId} + `); + + return result.map(id => zod.string().parse(id)); + } + + // --- Alert State Updates (used by evaluation engine) --- + + async updateRuleState(args: { + id: string; + state: MetricAlertRuleState; + stateChangedAt?: Date; + lastEvaluatedAt?: Date; + lastTriggeredAt?: Date; + }): Promise { + await this.pool.query(psql`/* updateRuleState */ + UPDATE "metric_alert_rules" + SET + "state" = ${args.state} + , "state_changed_at" = COALESCE(${args.stateChangedAt?.toISOString() ?? null}, "state_changed_at") + , "last_evaluated_at" = COALESCE(${args.lastEvaluatedAt?.toISOString() ?? null}, "last_evaluated_at") + , "last_triggered_at" = COALESCE(${args.lastTriggeredAt?.toISOString() ?? null}, "last_triggered_at") + , "updated_at" = NOW() + WHERE + "id" = ${args.id} + `); + } + + // --- Incidents --- + + async createIncident(args: { + ruleId: string; + currentValue: number; + previousValue: number | null; + thresholdValue: number; + }): Promise { + const result = await this.pool.one(psql`/* createIncident */ + INSERT INTO "metric_alert_incidents" ( + "metric_alert_rule_id" + , "current_value" + , "previous_value" + , "threshold_value" + ) + VALUES ( + ${args.ruleId} + , ${args.currentValue} + , ${args.previousValue} + , ${args.thresholdValue} + ) + RETURNING ${METRIC_ALERT_INCIDENT_SELECT} + `); + + return MetricAlertIncidentModel.parse(result) as MetricAlertIncident; + } + + async resolveIncident(args: { ruleId: string }): Promise { + const result = await this.pool.maybeOne(psql`/* resolveIncident */ + UPDATE "metric_alert_incidents" + SET "resolved_at" = NOW() + WHERE + "metric_alert_rule_id" = ${args.ruleId} + AND "resolved_at" IS NULL + RETURNING ${METRIC_ALERT_INCIDENT_SELECT} + `); + + if (result === null) { + return null; + } + + return MetricAlertIncidentModel.parse(result) as MetricAlertIncident; + } + + async getOpenIncident(args: { ruleId: string }): Promise { + const result = await this.pool.maybeOne(psql`/* getOpenIncident */ + SELECT ${METRIC_ALERT_INCIDENT_SELECT} + FROM "metric_alert_incidents" + WHERE + "metric_alert_rule_id" = ${args.ruleId} + AND "resolved_at" IS NULL + `); + + if (result === null) { + return null; + } + + return MetricAlertIncidentModel.parse(result) as MetricAlertIncident; + } + + async getIncidentHistory(args: { + ruleId: string; + limit: number; + offset: number; + }): Promise { + const result = await this.pool.any(psql`/* getIncidentHistory */ + SELECT ${METRIC_ALERT_INCIDENT_SELECT} + FROM "metric_alert_incidents" + WHERE "metric_alert_rule_id" = ${args.ruleId} + ORDER BY "started_at" DESC + LIMIT ${args.limit} + OFFSET ${args.offset} + `); + + return result.map(row => MetricAlertIncidentModel.parse(row) as MetricAlertIncident); + } + + // --- State Log --- + + async logStateTransition(args: { + ruleId: string; + targetId: string; + fromState: MetricAlertRuleState; + toState: MetricAlertRuleState; + value: number | null; + previousValue: number | null; + thresholdValue: number | null; + expiresAt: Date; + }): Promise { + const result = await this.pool.one(psql`/* logStateTransition */ + INSERT INTO "metric_alert_state_log" ( + "metric_alert_rule_id" + , "target_id" + , "from_state" + , "to_state" + , "value" + , "previous_value" + , "threshold_value" + , "expires_at" + ) + VALUES ( + ${args.ruleId} + , ${args.targetId} + , ${args.fromState} + , ${args.toState} + , ${args.value} + , ${args.previousValue} + , ${args.thresholdValue} + , ${args.expiresAt.toISOString()} + ) + RETURNING ${METRIC_ALERT_STATE_LOG_SELECT} + `); + + return MetricAlertStateLogModel.parse(result) as MetricAlertStateLogEntry; + } + + async getStateLog(args: { + ruleId: string; + from: Date; + to: Date; + }): Promise { + const result = await this.pool.any(psql`/* getStateLog */ + SELECT ${METRIC_ALERT_STATE_LOG_SELECT} + FROM "metric_alert_state_log" + WHERE + "metric_alert_rule_id" = ${args.ruleId} + AND "created_at" >= ${args.from.toISOString()} + AND "created_at" <= ${args.to.toISOString()} + ORDER BY "created_at" DESC + `); + + return result.map(row => MetricAlertStateLogModel.parse(row) as MetricAlertStateLogEntry); + } + + async getStateLogByTarget(args: { + targetId: string; + from: Date; + to: Date; + }): Promise { + const result = await this.pool.any(psql`/* getStateLogByTarget */ + SELECT ${METRIC_ALERT_STATE_LOG_SELECT} + FROM "metric_alert_state_log" + WHERE + "target_id" = ${args.targetId} + AND "created_at" >= ${args.from.toISOString()} + AND "created_at" <= ${args.to.toISOString()} + ORDER BY "created_at" DESC + `); + + return result.map(row => MetricAlertStateLogModel.parse(row) as MetricAlertStateLogEntry); + } + + async getEventCount(args: { + ruleId: string; + from: Date; + to: Date; + }): Promise { + const result = await this.pool.oneFirst(psql`/* getEventCount */ + SELECT count(*)::int + FROM "metric_alert_state_log" + WHERE + "metric_alert_rule_id" = ${args.ruleId} + AND "created_at" >= ${args.from.toISOString()} + AND "created_at" <= ${args.to.toISOString()} + `); + + return zod.number().parse(result); + } + + async purgeExpiredStateLog(): Promise { + const result = await this.pool.oneFirst(psql`/* purgeExpiredStateLog */ + WITH deleted AS ( + DELETE FROM "metric_alert_state_log" + WHERE "expires_at" < NOW() + RETURNING 1 + ) + SELECT count(*)::int FROM deleted + `); + + return zod.number().parse(result); + } +} diff --git a/packages/services/api/src/shared/entities.ts b/packages/services/api/src/shared/entities.ts index 3b1de84391..3e7b469e4d 100644 --- a/packages/services/api/src/shared/entities.ts +++ b/packages/services/api/src/shared/entities.ts @@ -436,6 +436,60 @@ export interface Alert { createdAt: string; } +export type MetricAlertRuleType = 'LATENCY' | 'ERROR_RATE' | 'TRAFFIC'; +export type MetricAlertRuleMetric = 'avg' | 'p75' | 'p90' | 'p95' | 'p99'; +export type MetricAlertRuleThresholdType = 'FIXED_VALUE' | 'PERCENTAGE_CHANGE'; +export type MetricAlertRuleDirection = 'ABOVE' | 'BELOW'; +export type MetricAlertRuleSeverity = 'INFO' | 'WARNING' | 'CRITICAL'; +export type MetricAlertRuleState = 'NORMAL' | 'PENDING' | 'FIRING' | 'RECOVERING'; + +export interface MetricAlertRule { + id: string; + organizationId: string; + projectId: string; + targetId: string; + type: MetricAlertRuleType; + timeWindowMinutes: number; + metric: MetricAlertRuleMetric | null; + thresholdType: MetricAlertRuleThresholdType; + thresholdValue: number; + direction: MetricAlertRuleDirection; + severity: MetricAlertRuleSeverity; + name: string; + createdAt: string; + updatedAt: string; + enabled: boolean; + lastEvaluatedAt: string | null; + lastTriggeredAt: string | null; + state: MetricAlertRuleState; + stateChangedAt: string | null; + confirmationMinutes: number; + savedFilterId: string | null; +} + +export interface MetricAlertIncident { + id: string; + metricAlertRuleId: string; + startedAt: string; + resolvedAt: string | null; + currentValue: number; + previousValue: number | null; + thresholdValue: number; +} + +export interface MetricAlertStateLogEntry { + id: string; + metricAlertRuleId: string; + targetId: string; + fromState: MetricAlertRuleState; + toState: MetricAlertRuleState; + value: number | null; + previousValue: number | null; + thresholdValue: number | null; + createdAt: string; + expiresAt: string; +} + export interface AdminOrganizationStats { organization: Organization; versions: number; From e38889bf2349575dc80b1cd8fd35f2e3af5e469d Mon Sep 17 00:00:00 2001 From: Jonathan Brennan Date: Thu, 16 Apr 2026 19:39:23 -0500 Subject: [PATCH 11/18] generate --- packages/services/storage/src/db/types.ts | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/packages/services/storage/src/db/types.ts b/packages/services/storage/src/db/types.ts index 224b9403d7..ae52d5ead1 100644 --- a/packages/services/storage/src/db/types.ts +++ b/packages/services/storage/src/db/types.ts @@ -159,15 +159,6 @@ export interface migration { name: string; } -export interface oidc_integration_domains { - created_at: Date; - domain_name: string; - id: string; - oidc_integration_id: string; - organization_id: string; - verified_at: Date | null; -} - export interface oidc_integrations { additional_scopes: Array | null; authorization_endpoint: string | null; @@ -191,7 +182,6 @@ export interface organization_access_tokens { assigned_resources: any | null; created_at: Date; description: string; - expires_at: Date | null; first_characters: string; hash: string; id: string; @@ -393,9 +383,6 @@ export interface schema_proposal_reviews { export interface schema_proposals { author: string; comments_count: number; - composition_status: string | null; - composition_status_reason: string | null; - composition_timestamp: Date | null; created_at: Date; description: string; id: string; @@ -540,7 +527,6 @@ export interface DBTables { email_verifications: email_verifications; graphile_worker_deduplication: graphile_worker_deduplication; migration: migration; - oidc_integration_domains: oidc_integration_domains; oidc_integrations: oidc_integrations; organization_access_tokens: organization_access_tokens; organization_invitations: organization_invitations; From e92f120a7b396b4adf80a733c432b3fe7b3b3386 Mon Sep 17 00:00:00 2001 From: Jonathan Brennan Date: Thu, 16 Apr 2026 19:40:27 -0500 Subject: [PATCH 12/18] add GraphQL schema and resolvers for metric alert rules --- .../modules/alerts/module.graphql.mappers.ts | 11 +- .../api/src/modules/alerts/module.graphql.ts | 189 ++++++++++++++++++ .../providers/metric-alert-rules-storage.ts | 2 +- .../alerts/resolvers/MetricAlertRule.ts | 55 +++++ .../resolvers/MetricAlertRuleIncident.ts | 14 ++ .../resolvers/MetricAlertRuleStateChange.ts | 16 ++ .../resolvers/Mutation/addMetricAlertRule.ts | 68 +++++++ .../Mutation/deleteMetricAlertRules.ts | 31 +++ .../Mutation/updateMetricAlertRule.ts | 61 ++++++ .../src/modules/alerts/resolvers/Project.ts | 8 +- .../src/modules/alerts/resolvers/Target.ts | 12 ++ 11 files changed, 464 insertions(+), 3 deletions(-) create mode 100644 packages/services/api/src/modules/alerts/resolvers/MetricAlertRule.ts create mode 100644 packages/services/api/src/modules/alerts/resolvers/MetricAlertRuleIncident.ts create mode 100644 packages/services/api/src/modules/alerts/resolvers/MetricAlertRuleStateChange.ts create mode 100644 packages/services/api/src/modules/alerts/resolvers/Mutation/addMetricAlertRule.ts create mode 100644 packages/services/api/src/modules/alerts/resolvers/Mutation/deleteMetricAlertRules.ts create mode 100644 packages/services/api/src/modules/alerts/resolvers/Mutation/updateMetricAlertRule.ts create mode 100644 packages/services/api/src/modules/alerts/resolvers/Target.ts diff --git a/packages/services/api/src/modules/alerts/module.graphql.mappers.ts b/packages/services/api/src/modules/alerts/module.graphql.mappers.ts index c34397be64..ff1d30feb7 100644 --- a/packages/services/api/src/modules/alerts/module.graphql.mappers.ts +++ b/packages/services/api/src/modules/alerts/module.graphql.mappers.ts @@ -1,7 +1,16 @@ -import type { Alert, AlertChannel } from '../../shared/entities'; +import type { + Alert, + AlertChannel, + MetricAlertIncident, + MetricAlertRule, + MetricAlertStateLogEntry, +} from '../../shared/entities'; export type AlertChannelMapper = AlertChannel; export type AlertSlackChannelMapper = AlertChannel; export type AlertWebhookChannelMapper = AlertChannel; export type TeamsWebhookChannelMapper = AlertChannel; export type AlertMapper = Alert; +export type MetricAlertRuleMapper = MetricAlertRule; +export type MetricAlertRuleIncidentMapper = MetricAlertIncident; +export type MetricAlertRuleStateChangeMapper = MetricAlertStateLogEntry; diff --git a/packages/services/api/src/modules/alerts/module.graphql.ts b/packages/services/api/src/modules/alerts/module.graphql.ts index 2338f6e2c4..25d9679835 100644 --- a/packages/services/api/src/modules/alerts/module.graphql.ts +++ b/packages/services/api/src/modules/alerts/module.graphql.ts @@ -154,4 +154,193 @@ export default gql` channel: AlertChannel! target: Target! } + + # --- Metric Alert Rules --- + + enum MetricAlertRuleType { + LATENCY + ERROR_RATE + TRAFFIC + } + + enum MetricAlertRuleMetric { + avg + p75 + p90 + p95 + p99 + } + + enum MetricAlertRuleThresholdType { + FIXED_VALUE + PERCENTAGE_CHANGE + } + + enum MetricAlertRuleDirection { + ABOVE + BELOW + } + + enum MetricAlertRuleSeverity { + INFO + WARNING + CRITICAL + } + + enum MetricAlertRuleState { + NORMAL + PENDING + FIRING + RECOVERING + } + + type MetricAlertRule { + id: ID! + name: String! + type: MetricAlertRuleType! + target: Target! + """Destinations that receive notifications when this rule fires or resolves.""" + channels: [AlertChannel!]! + timeWindowMinutes: Int! + metric: MetricAlertRuleMetric + thresholdType: MetricAlertRuleThresholdType! + thresholdValue: Float! + direction: MetricAlertRuleDirection! + severity: MetricAlertRuleSeverity! + state: MetricAlertRuleState! + confirmationMinutes: Int! + enabled: Boolean! + lastEvaluatedAt: DateTime + """Most recent time this rule transitioned PENDING → FIRING (null if never fired).""" + lastTriggeredAt: DateTime + createdAt: DateTime! + """The saved filter that scopes this rule (null = applies to the whole target).""" + savedFilter: SavedFilter + """Count of state transitions logged for this rule in the given time range.""" + eventCount(from: DateTime!, to: DateTime!): Int! + """The currently open incident, if any.""" + currentIncident: MetricAlertRuleIncident + """Past incidents for this alert rule.""" + incidentHistory(limit: Int, offset: Int): [MetricAlertRuleIncident!]! + """State change history for this rule (powers the state timeline).""" + stateLog(from: DateTime!, to: DateTime!): [MetricAlertRuleStateChange!]! + } + + type MetricAlertRuleIncident { + id: ID! + startedAt: DateTime! + resolvedAt: DateTime + currentValue: Float! + previousValue: Float + thresholdValue: Float! + } + + type MetricAlertRuleStateChange { + id: ID! + fromState: MetricAlertRuleState! + toState: MetricAlertRuleState! + """Metric value in the current window at transition time.""" + value: Float + """Metric value in the previous (comparison) window at transition time.""" + previousValue: Float + """Threshold value snapshotted at transition time (survives rule edits).""" + thresholdValue: Float + createdAt: DateTime! + rule: MetricAlertRule! + } + + extend type Target { + """State changes across all alert rules for this target (powers the alert events chart + list).""" + metricAlertRuleStateLog(from: DateTime!, to: DateTime!): [MetricAlertRuleStateChange!]! + } + + extend type Project { + metricAlertRules: [MetricAlertRule!]! + } + + extend type Mutation { + addMetricAlertRule(input: AddMetricAlertRuleInput!): AddMetricAlertRuleResult! + updateMetricAlertRule(input: UpdateMetricAlertRuleInput!): UpdateMetricAlertRuleResult! + deleteMetricAlertRules(input: DeleteMetricAlertRulesInput!): DeleteMetricAlertRulesResult! + } + + input AddMetricAlertRuleInput { + organizationSlug: String! + projectSlug: String! + targetSlug: String! + name: String! + type: MetricAlertRuleType! + timeWindowMinutes: Int! + metric: MetricAlertRuleMetric + thresholdType: MetricAlertRuleThresholdType! + thresholdValue: Float! + direction: MetricAlertRuleDirection! + severity: MetricAlertRuleSeverity! + confirmationMinutes: Int + channelIds: [ID!]! + savedFilterId: ID + } + + input UpdateMetricAlertRuleInput { + organizationSlug: String! + projectSlug: String! + ruleId: ID! + name: String + type: MetricAlertRuleType + timeWindowMinutes: Int + metric: MetricAlertRuleMetric + thresholdType: MetricAlertRuleThresholdType + thresholdValue: Float + direction: MetricAlertRuleDirection + severity: MetricAlertRuleSeverity + confirmationMinutes: Int + channelIds: [ID!] + savedFilterId: ID + enabled: Boolean + } + + input DeleteMetricAlertRulesInput { + organizationSlug: String! + projectSlug: String! + ruleIds: [ID!]! + } + + type AddMetricAlertRuleResult { + ok: AddMetricAlertRuleOk + error: AddMetricAlertRuleError + } + + type AddMetricAlertRuleOk { + addedMetricAlertRule: MetricAlertRule! + } + + type AddMetricAlertRuleError implements Error { + message: String! + } + + type UpdateMetricAlertRuleResult { + ok: UpdateMetricAlertRuleOk + error: UpdateMetricAlertRuleError + } + + type UpdateMetricAlertRuleOk { + updatedMetricAlertRule: MetricAlertRule! + } + + type UpdateMetricAlertRuleError implements Error { + message: String! + } + + type DeleteMetricAlertRulesResult { + ok: DeleteMetricAlertRulesOk + error: DeleteMetricAlertRulesError + } + + type DeleteMetricAlertRulesOk { + deletedMetricAlertRuleIds: [ID!]! + } + + type DeleteMetricAlertRulesError implements Error { + message: String! + } `; diff --git a/packages/services/api/src/modules/alerts/providers/metric-alert-rules-storage.ts b/packages/services/api/src/modules/alerts/providers/metric-alert-rules-storage.ts index c4993c12ca..15ac4f1639 100644 --- a/packages/services/api/src/modules/alerts/providers/metric-alert-rules-storage.ts +++ b/packages/services/api/src/modules/alerts/providers/metric-alert-rules-storage.ts @@ -271,7 +271,7 @@ export class MetricAlertRulesStorage { ruleId: string; channelIds: string[]; }): Promise { - await this.pool.transaction(async trx => { + await this.pool.transaction('setRuleChannels', async trx => { await trx.query(psql` DELETE FROM "metric_alert_rule_channels" WHERE "metric_alert_rule_id" = ${args.ruleId} diff --git a/packages/services/api/src/modules/alerts/resolvers/MetricAlertRule.ts b/packages/services/api/src/modules/alerts/resolvers/MetricAlertRule.ts new file mode 100644 index 0000000000..4fa81b5a94 --- /dev/null +++ b/packages/services/api/src/modules/alerts/resolvers/MetricAlertRule.ts @@ -0,0 +1,55 @@ +import { SavedFiltersStorage } from '../../saved-filters/providers/saved-filters-storage'; +import { TargetManager } from '../../target/providers/target-manager'; +import { AlertsManager } from '../providers/alerts-manager'; +import { MetricAlertRulesStorage } from '../providers/metric-alert-rules-storage'; +import type { MetricAlertRuleResolvers } from './../../../__generated__/types'; + +export const MetricAlertRule: MetricAlertRuleResolvers = { + target: (rule, _, { injector }) => { + return injector.get(TargetManager).getTarget({ + targetId: rule.targetId, + projectId: rule.projectId, + organizationId: rule.organizationId, + }); + }, + channels: async (rule, _, { injector }) => { + const channelIds = await injector.get(MetricAlertRulesStorage).getRuleChannelIds({ + ruleId: rule.id, + }); + const allChannels = await injector.get(AlertsManager).getChannels({ + organizationId: rule.organizationId, + projectId: rule.projectId, + }); + return allChannels.filter(c => channelIds.includes(c.id)); + }, + savedFilter: (rule, _, { injector }) => { + if (!rule.savedFilterId) { + return null; + } + return injector.get(SavedFiltersStorage).getSavedFilter({ id: rule.savedFilterId }); + }, + eventCount: (rule, { from, to }, { injector }) => { + return injector.get(MetricAlertRulesStorage).getEventCount({ + ruleId: rule.id, + from: new Date(from), + to: new Date(to), + }); + }, + currentIncident: (rule, _, { injector }) => { + return injector.get(MetricAlertRulesStorage).getOpenIncident({ ruleId: rule.id }); + }, + incidentHistory: (rule, { limit, offset }, { injector }) => { + return injector.get(MetricAlertRulesStorage).getIncidentHistory({ + ruleId: rule.id, + limit: limit ?? 20, + offset: offset ?? 0, + }); + }, + stateLog: (rule, { from, to }, { injector }) => { + return injector.get(MetricAlertRulesStorage).getStateLog({ + ruleId: rule.id, + from: new Date(from), + to: new Date(to), + }); + }, +}; diff --git a/packages/services/api/src/modules/alerts/resolvers/MetricAlertRuleIncident.ts b/packages/services/api/src/modules/alerts/resolvers/MetricAlertRuleIncident.ts new file mode 100644 index 0000000000..f7611a509f --- /dev/null +++ b/packages/services/api/src/modules/alerts/resolvers/MetricAlertRuleIncident.ts @@ -0,0 +1,14 @@ +import type { MetricAlertRuleIncidentResolvers } from './../../../__generated__/types'; + +/* + * Note: This object type is generated because "MetricAlertRuleIncidentMapper" is declared. This is to ensure runtime safety. + * + * When a mapper is used, it is possible to hit runtime errors in some scenarios: + * - given a field name, the schema type's field type does not match mapper's field type + * - or a schema type's field does not exist in the mapper's fields + * + * If you want to skip this file generation, remove the mapper or update the pattern in the `resolverGeneration.object` config. + */ +export const MetricAlertRuleIncident: MetricAlertRuleIncidentResolvers = { + /* Implement MetricAlertRuleIncident resolver logic here */ +}; diff --git a/packages/services/api/src/modules/alerts/resolvers/MetricAlertRuleStateChange.ts b/packages/services/api/src/modules/alerts/resolvers/MetricAlertRuleStateChange.ts new file mode 100644 index 0000000000..a7e3c27544 --- /dev/null +++ b/packages/services/api/src/modules/alerts/resolvers/MetricAlertRuleStateChange.ts @@ -0,0 +1,16 @@ +import { MetricAlertRulesStorage } from '../providers/metric-alert-rules-storage'; +import type { MetricAlertRuleStateChangeResolvers } from './../../../__generated__/types'; + +export const MetricAlertRuleStateChange: MetricAlertRuleStateChangeResolvers = { + rule: async (entry, _, { injector }) => { + const rule = await injector + .get(MetricAlertRulesStorage) + .getMetricAlertRule({ id: entry.metricAlertRuleId }); + + if (!rule) { + throw new Error(`Metric alert rule ${entry.metricAlertRuleId} not found`); + } + + return rule; + }, +}; diff --git a/packages/services/api/src/modules/alerts/resolvers/Mutation/addMetricAlertRule.ts b/packages/services/api/src/modules/alerts/resolvers/Mutation/addMetricAlertRule.ts new file mode 100644 index 0000000000..752f4888a7 --- /dev/null +++ b/packages/services/api/src/modules/alerts/resolvers/Mutation/addMetricAlertRule.ts @@ -0,0 +1,68 @@ +import { Session } from '../../../auth/lib/authz'; +import { IdTranslator } from '../../../shared/providers/id-translator'; +import { MetricAlertRulesStorage } from '../../providers/metric-alert-rules-storage'; +import type { MutationResolvers } from './../../../../__generated__/types'; + +export const addMetricAlertRule: NonNullable = async ( + _, + { input }, + { injector }, +) => { + const translator = injector.get(IdTranslator); + const [organizationId, projectId, targetId] = await Promise.all([ + translator.translateOrganizationId(input), + translator.translateProjectId(input), + translator.translateTargetId(input), + ]); + + await injector.get(Session).assertPerformAction({ + action: 'alert:modify', + organizationId, + params: { organizationId, projectId }, + }); + + if (input.type === 'LATENCY' && !input.metric) { + return { + error: { message: 'Metric is required for LATENCY alert type.' }, + }; + } + + if (input.type !== 'LATENCY' && input.metric) { + return { + error: { message: 'Metric should only be set for LATENCY alert type.' }, + }; + } + + if (input.channelIds.length === 0) { + return { + error: { message: 'At least one channel is required.' }, + }; + } + + const storage = injector.get(MetricAlertRulesStorage); + + const rule = await storage.addMetricAlertRule({ + organizationId, + projectId, + targetId, + type: input.type, + timeWindowMinutes: input.timeWindowMinutes, + metric: input.metric ?? null, + thresholdType: input.thresholdType, + thresholdValue: input.thresholdValue, + direction: input.direction, + severity: input.severity, + name: input.name, + confirmationMinutes: input.confirmationMinutes ?? 0, + savedFilterId: input.savedFilterId ?? null, + }); + + await storage.setRuleChannels({ + ruleId: rule.id, + channelIds: [...input.channelIds], + }); + + return { + ok: { addedMetricAlertRule: rule }, + }; +}; diff --git a/packages/services/api/src/modules/alerts/resolvers/Mutation/deleteMetricAlertRules.ts b/packages/services/api/src/modules/alerts/resolvers/Mutation/deleteMetricAlertRules.ts new file mode 100644 index 0000000000..64dd6ea80f --- /dev/null +++ b/packages/services/api/src/modules/alerts/resolvers/Mutation/deleteMetricAlertRules.ts @@ -0,0 +1,31 @@ +import { Session } from '../../../auth/lib/authz'; +import { IdTranslator } from '../../../shared/providers/id-translator'; +import { MetricAlertRulesStorage } from '../../providers/metric-alert-rules-storage'; +import type { MutationResolvers } from './../../../../__generated__/types'; + +export const deleteMetricAlertRules: NonNullable< + MutationResolvers['deleteMetricAlertRules'] +> = async (_, { input }, { injector }) => { + const translator = injector.get(IdTranslator); + const [organizationId, projectId] = await Promise.all([ + translator.translateOrganizationId(input), + translator.translateProjectId(input), + ]); + + await injector.get(Session).assertPerformAction({ + action: 'alert:modify', + organizationId, + params: { organizationId, projectId }, + }); + + const deleted = await injector.get(MetricAlertRulesStorage).deleteMetricAlertRules({ + projectId, + ruleIds: [...input.ruleIds], + }); + + return { + ok: { + deletedMetricAlertRuleIds: deleted.map(r => r.id), + }, + }; +}; diff --git a/packages/services/api/src/modules/alerts/resolvers/Mutation/updateMetricAlertRule.ts b/packages/services/api/src/modules/alerts/resolvers/Mutation/updateMetricAlertRule.ts new file mode 100644 index 0000000000..d95b0e9bc2 --- /dev/null +++ b/packages/services/api/src/modules/alerts/resolvers/Mutation/updateMetricAlertRule.ts @@ -0,0 +1,61 @@ +import { Session } from '../../../auth/lib/authz'; +import { IdTranslator } from '../../../shared/providers/id-translator'; +import { MetricAlertRulesStorage } from '../../providers/metric-alert-rules-storage'; +import type { MutationResolvers } from './../../../../__generated__/types'; + +export const updateMetricAlertRule: NonNullable< + MutationResolvers['updateMetricAlertRule'] +> = async (_, { input }, { injector }) => { + const translator = injector.get(IdTranslator); + const [organizationId, projectId] = await Promise.all([ + translator.translateOrganizationId(input), + translator.translateProjectId(input), + ]); + + await injector.get(Session).assertPerformAction({ + action: 'alert:modify', + organizationId, + params: { organizationId, projectId }, + }); + + const storage = injector.get(MetricAlertRulesStorage); + + const existing = await storage.getMetricAlertRule({ id: input.ruleId }); + if (!existing || existing.projectId !== projectId) { + return { + error: { message: 'Metric alert rule not found.' }, + }; + } + + const rule = await storage.updateMetricAlertRule({ + id: input.ruleId, + type: input.type ?? undefined, + timeWindowMinutes: input.timeWindowMinutes ?? undefined, + metric: input.metric ?? undefined, + thresholdType: input.thresholdType ?? undefined, + thresholdValue: input.thresholdValue ?? undefined, + direction: input.direction ?? undefined, + severity: input.severity ?? undefined, + name: input.name ?? undefined, + confirmationMinutes: input.confirmationMinutes ?? undefined, + savedFilterId: input.savedFilterId ?? undefined, + enabled: input.enabled ?? undefined, + }); + + if (!rule) { + return { + error: { message: 'Failed to update metric alert rule.' }, + }; + } + + if (input.channelIds) { + await storage.setRuleChannels({ + ruleId: rule.id, + channelIds: [...input.channelIds], + }); + } + + return { + ok: { updatedMetricAlertRule: rule }, + }; +}; diff --git a/packages/services/api/src/modules/alerts/resolvers/Project.ts b/packages/services/api/src/modules/alerts/resolvers/Project.ts index 4af1231705..fd5b6b6992 100644 --- a/packages/services/api/src/modules/alerts/resolvers/Project.ts +++ b/packages/services/api/src/modules/alerts/resolvers/Project.ts @@ -1,7 +1,8 @@ import { AlertsManager } from '../providers/alerts-manager'; +import { MetricAlertRulesStorage } from '../providers/metric-alert-rules-storage'; import type { ProjectResolvers } from './../../../__generated__/types'; -export const Project: Pick = { +export const Project: Pick = { alerts: async (project, _, { injector }) => { return injector.get(AlertsManager).getAlerts({ organizationId: project.orgId, @@ -14,4 +15,9 @@ export const Project: Pick = { projectId: project.id, }); }, + metricAlertRules: async (project, _, { injector }) => { + return injector.get(MetricAlertRulesStorage).getMetricAlertRules({ + projectId: project.id, + }); + }, }; diff --git a/packages/services/api/src/modules/alerts/resolvers/Target.ts b/packages/services/api/src/modules/alerts/resolvers/Target.ts new file mode 100644 index 0000000000..9104553830 --- /dev/null +++ b/packages/services/api/src/modules/alerts/resolvers/Target.ts @@ -0,0 +1,12 @@ +import { MetricAlertRulesStorage } from '../providers/metric-alert-rules-storage'; +import type { TargetResolvers } from './../../../__generated__/types'; + +export const Target: Pick = { + metricAlertRuleStateLog: (target, { from, to }, { injector }) => { + return injector.get(MetricAlertRulesStorage).getStateLogByTarget({ + targetId: target.id, + from: new Date(from), + to: new Date(to), + }); + }, +}; From 10af3ffb5c7f943e15187702bff7b154b95e0b64 Mon Sep 17 00:00:00 2001 From: Jonathan Brennan Date: Thu, 16 Apr 2026 19:53:49 -0500 Subject: [PATCH 13/18] add metric alert evaluation engine to workflows service --- packages/services/workflows/src/context.ts | 2 + .../services/workflows/src/environment.ts | 26 ++ packages/services/workflows/src/index.ts | 12 + .../workflows/src/lib/clickhouse-client.ts | 50 +++ .../src/lib/metric-alert-evaluator.ts | 334 ++++++++++++++++++ .../src/tasks/evaluate-metric-alert-rules.ts | 80 +++++ .../tasks/purge-expired-alert-state-log.ts | 25 ++ 7 files changed, 529 insertions(+) create mode 100644 packages/services/workflows/src/lib/clickhouse-client.ts create mode 100644 packages/services/workflows/src/lib/metric-alert-evaluator.ts create mode 100644 packages/services/workflows/src/tasks/evaluate-metric-alert-rules.ts create mode 100644 packages/services/workflows/src/tasks/purge-expired-alert-state-log.ts diff --git a/packages/services/workflows/src/context.ts b/packages/services/workflows/src/context.ts index cfc676545e..8a5490f5ef 100644 --- a/packages/services/workflows/src/context.ts +++ b/packages/services/workflows/src/context.ts @@ -1,6 +1,7 @@ import type { Logger } from '@graphql-hive/logger'; import { PostgresDatabasePool } from '@hive/postgres'; import type { HivePubSub } from '@hive/pubsub'; +import type { ClickHouseClient } from './lib/clickhouse-client.js'; import type { EmailProvider } from './lib/emails/providers.js'; import type { SchemaProvider } from './lib/schema/provider.js'; import type { RequestBroker } from './lib/webhooks/send-webhook.js'; @@ -10,6 +11,7 @@ export type Context = { email: EmailProvider; schema: SchemaProvider; pg: PostgresDatabasePool; + clickhouse: ClickHouseClient | null; requestBroker: RequestBroker | null; pubSub: HivePubSub; }; diff --git a/packages/services/workflows/src/environment.ts b/packages/services/workflows/src/environment.ts index 32ee8ee8be..c0ff547a11 100644 --- a/packages/services/workflows/src/environment.ts +++ b/packages/services/workflows/src/environment.ts @@ -90,6 +90,20 @@ const RedisModel = zod.object({ REDIS_TLS_ENABLED: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()), }); +const ClickHouseModel = zod.union([ + zod.object({ + CLICKHOUSE: emptyString(zod.literal('0').optional()), + }), + zod.object({ + CLICKHOUSE: zod.literal('1'), + CLICKHOUSE_HOST: zod.string(), + CLICKHOUSE_PORT: NumberFromString, + CLICKHOUSE_USERNAME: zod.string(), + CLICKHOUSE_PASSWORD: emptyString(zod.string().optional()), + CLICKHOUSE_PROTOCOL: emptyString(zod.string().optional()), + }), +]); + const RequestBrokerModel = zod.union([ zod.object({ REQUEST_BROKER: emptyString(zod.literal('0').optional()), @@ -134,6 +148,7 @@ const configs = { prometheus: PrometheusModel.safeParse(process.env), log: LogModel.safeParse(process.env), tracing: OpenTelemetryConfigurationModel.safeParse(process.env), + clickhouse: ClickHouseModel.safeParse(process.env), requestBroker: RequestBrokerModel.safeParse(process.env), redis: RedisModel.safeParse(process.env), }; @@ -166,6 +181,7 @@ const sentry = extractConfig(configs.sentry); const prometheus = extractConfig(configs.prometheus); const log = extractConfig(configs.log); const tracing = extractConfig(configs.tracing); +const clickhouse = extractConfig(configs.clickhouse); const requestBroker = extractConfig(configs.requestBroker); const redis = extractConfig(configs.redis); @@ -240,6 +256,16 @@ export const env = { user: postgres.POSTGRES_USER, } satisfies PostgresConnectionParamaters, }, + clickhouse: + clickhouse.CLICKHOUSE === '1' + ? { + host: clickhouse.CLICKHOUSE_HOST, + port: clickhouse.CLICKHOUSE_PORT, + username: clickhouse.CLICKHOUSE_USERNAME, + password: clickhouse.CLICKHOUSE_PASSWORD ?? '', + protocol: clickhouse.CLICKHOUSE_PROTOCOL, + } + : null, requestBroker: requestBroker.REQUEST_BROKER === '1' ? ({ diff --git a/packages/services/workflows/src/index.ts b/packages/services/workflows/src/index.ts index d14be17d57..c57db291de 100644 --- a/packages/services/workflows/src/index.ts +++ b/packages/services/workflows/src/index.ts @@ -12,6 +12,7 @@ import { } from '@hive/service-common'; import { Context } from './context.js'; import { env } from './environment.js'; +import { ClickHouseClient } from './lib/clickhouse-client.js'; import { createEmailProvider } from './lib/emails/providers.js'; import { schemaProvider } from './lib/schema/provider.js'; import { bridgeFastifyLogger } from './logger.js'; @@ -43,6 +44,8 @@ const modules = await Promise.all([ import('./tasks/usage-rate-limit-exceeded.js'), import('./tasks/usage-rate-limit-warning.js'), import('./tasks/schema-proposal-composition.js'), + import('./tasks/evaluate-metric-alert-rules.js'), + import('./tasks/purge-expired-alert-state-log.js'), ]); const crontab = ` @@ -50,6 +53,10 @@ const crontab = ` 0 10 * * 0 purgeExpiredSchemaChecks # Every day at 3:00 AM 0 3 * * * purgeExpiredDedupeKeys + # Evaluate metric alert rules every minute + * * * * * evaluateMetricAlertRules + # Purge expired alert state log entries daily at 4:00 AM + 0 4 * * * purgeExpiredAlertStateLog `; const pg = await createPostgresDatabasePool({ @@ -86,10 +93,15 @@ const pubSub = createHivePubSub({ ), }); +const clickhouse = env.clickhouse + ? new ClickHouseClient(env.clickhouse, logger.child({ source: 'ClickHouse' })) + : null; + const context: Context = { logger, email: createEmailProvider(env.email.provider, env.email.emailFrom), pg, + clickhouse, requestBroker: env.requestBroker, schema: schemaProvider({ logger, diff --git a/packages/services/workflows/src/lib/clickhouse-client.ts b/packages/services/workflows/src/lib/clickhouse-client.ts new file mode 100644 index 0000000000..a187f02246 --- /dev/null +++ b/packages/services/workflows/src/lib/clickhouse-client.ts @@ -0,0 +1,50 @@ +import got from 'got'; +import type { Logger } from '@graphql-hive/logger'; + +export type ClickHouseConfig = { + host: string; + port: number; + username: string; + password: string; + protocol?: string; +}; + +export class ClickHouseClient { + private baseUrl: string; + + constructor( + private config: ClickHouseConfig, + private logger: Logger, + ) { + const protocol = config.protocol ?? 'http'; + this.baseUrl = `${protocol}://${config.host}:${config.port}`; + } + + async query>(sql: string): Promise { + this.logger.debug('Executing ClickHouse query'); + + const response = await got.post(this.baseUrl, { + searchParams: { + database: 'default', + default_format: 'JSON', + }, + headers: { + 'Content-Type': 'text/plain', + }, + username: this.config.username, + password: this.config.password, + body: sql, + timeout: { + request: 10_000, + }, + retry: { + limit: 3, + methods: ['POST'], + statusCodes: [502, 503, 504], + }, + }); + + const result = JSON.parse(response.body) as { data: T[] }; + return result.data; + } +} diff --git a/packages/services/workflows/src/lib/metric-alert-evaluator.ts b/packages/services/workflows/src/lib/metric-alert-evaluator.ts new file mode 100644 index 0000000000..1932337966 --- /dev/null +++ b/packages/services/workflows/src/lib/metric-alert-evaluator.ts @@ -0,0 +1,334 @@ +import type { Logger } from '@graphql-hive/logger'; +import type { PostgresDatabasePool } from '@hive/postgres'; +import { psql } from '@hive/postgres'; +import type { ClickHouseClient } from './clickhouse-client.js'; + +export type MetricAlertRuleRow = { + id: string; + organizationId: string; + projectId: string; + targetId: string; + type: 'LATENCY' | 'ERROR_RATE' | 'TRAFFIC'; + timeWindowMinutes: number; + metric: 'avg' | 'p75' | 'p90' | 'p95' | 'p99' | null; + thresholdType: 'FIXED_VALUE' | 'PERCENTAGE_CHANGE'; + thresholdValue: number; + direction: 'ABOVE' | 'BELOW'; + state: 'NORMAL' | 'PENDING' | 'FIRING' | 'RECOVERING'; + stateChangedAt: string | null; + confirmationMinutes: number; + savedFilterId: string | null; +}; + +type ClickHouseWindowRow = { + window: 'current' | 'previous'; + total: string; + total_ok: string; + average: number; + percentiles: [number, number, number, number]; +}; + +type GroupKey = string; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function assertUUID(value: string): string { + if (!UUID_RE.test(value)) { + throw new Error(`Invalid UUID: ${value}`); + } + return value; +} + +function makeGroupKey(rule: MetricAlertRuleRow): GroupKey { + return `${rule.targetId}:${rule.timeWindowMinutes}:${rule.savedFilterId ?? ''}`; +} + +function extractMetricValue(row: ClickHouseWindowRow, rule: MetricAlertRuleRow): number { + const total = Number(row.total); + const totalOk = Number(row.total_ok); + + switch (rule.type) { + case 'TRAFFIC': + return total; + case 'ERROR_RATE': + return total > 0 ? ((total - totalOk) / total) * 100 : 0; + case 'LATENCY': { + const metricMap: Record = { + avg: row.average, + p75: row.percentiles[0], + p90: row.percentiles[1], + p95: row.percentiles[2], + p99: row.percentiles[3], + }; + return metricMap[rule.metric!] ?? 0; + } + } +} + +function isThresholdBreached( + currentValue: number, + previousValue: number, + rule: MetricAlertRuleRow, +): boolean { + let compareValue: number; + + if (rule.thresholdType === 'FIXED_VALUE') { + compareValue = currentValue; + } else { + if (previousValue === 0) { + if (currentValue === 0) return false; + compareValue = currentValue; + } else { + compareValue = ((currentValue - previousValue) / previousValue) * 100; + } + } + + return rule.direction === 'ABOVE' + ? compareValue > rule.thresholdValue + : compareValue < rule.thresholdValue; +} + +function hasElapsed(stateChangedAt: string | null, minutes: number): boolean { + if (!stateChangedAt) return true; + const changedAt = new Date(stateChangedAt).getTime(); + return Date.now() - changedAt >= minutes * 60_000; +} + +function formatClickHouseDate(date: Date): string { + return date.toISOString().replace('T', ' ').replace('Z', '').slice(0, 19); +} + +export async function fetchEnabledRules(pg: PostgresDatabasePool): Promise { + const result = await pg.any(psql` + SELECT + "id" + , "organization_id" as "organizationId" + , "project_id" as "projectId" + , "target_id" as "targetId" + , "type" + , "time_window_minutes" as "timeWindowMinutes" + , "metric" + , "threshold_type" as "thresholdType" + , "threshold_value" as "thresholdValue" + , "direction" + , "state" + , to_json("state_changed_at") as "stateChangedAt" + , "confirmation_minutes" as "confirmationMinutes" + , "saved_filter_id" as "savedFilterId" + FROM "metric_alert_rules" + WHERE "enabled" = true + `); + + return result as unknown as MetricAlertRuleRow[]; +} + +export function groupRulesByQuery( + rules: MetricAlertRuleRow[], +): Map { + const groups = new Map(); + for (const rule of rules) { + const key = makeGroupKey(rule); + const group = groups.get(key); + if (group) { + group.push(rule); + } else { + groups.set(key, [rule]); + } + } + return groups; +} + +export async function queryClickHouseWindows( + clickhouse: ClickHouseClient, + targetId: string, + timeWindowMinutes: number, +): Promise<{ current: ClickHouseWindowRow | null; previous: ClickHouseWindowRow | null }> { + const now = Date.now(); + const offsetMs = 60_000; + const windowMs = timeWindowMinutes * 60_000; + + const currentWindowEnd = new Date(now - offsetMs); + const currentWindowStart = new Date(now - offsetMs - windowMs); + const previousWindowStart = new Date(now - offsetMs - 2 * windowMs); + + const tableName = timeWindowMinutes <= 360 ? 'operations_minutely' : 'operations_hourly'; + + const safeTargetId = assertUUID(targetId); + + // ClickHouse parameterized query syntax is not supported via the HTTP interface + // in the same way as PostgreSQL. We validate the UUID format above to prevent injection. + const sql = ` + SELECT + CASE + WHEN timestamp >= '${formatClickHouseDate(currentWindowStart)}' THEN 'current' + ELSE 'previous' + END as window, + sum(total) as total, + sum(total_ok) as total_ok, + avgMerge(duration_avg) as average, + quantilesMerge(0.75, 0.90, 0.95, 0.99)(duration_quantiles) as percentiles + FROM ${tableName} + WHERE target = '${safeTargetId}' + AND timestamp >= '${formatClickHouseDate(previousWindowStart)}' + AND timestamp < '${formatClickHouseDate(currentWindowEnd)}' + GROUP BY window + ORDER BY window + `; + + const rows = await clickhouse.query(sql); + + return { + current: rows.find(r => r.window === 'current') ?? null, + previous: rows.find(r => r.window === 'previous') ?? null, + }; +} + +export async function evaluateRule(args: { + rule: MetricAlertRuleRow; + current: ClickHouseWindowRow; + previous: ClickHouseWindowRow; + pg: PostgresDatabasePool; + logger: Logger; +}): Promise { + const { rule, current, previous, pg, logger } = args; + const now = new Date(); + + const currentValue = extractMetricValue(current, rule); + const previousValue = extractMetricValue(previous, rule); + const breached = isThresholdBreached(currentValue, previousValue, rule); + + if (breached) { + switch (rule.state) { + case 'NORMAL': { + await updateState(pg, rule.id, 'PENDING', now); + await logTransition(pg, rule, 'NORMAL', 'PENDING', currentValue, previousValue); + logger.info({ ruleId: rule.id }, 'Alert rule entered PENDING state'); + break; + } + case 'PENDING': { + if ( + rule.confirmationMinutes === 0 || + hasElapsed(rule.stateChangedAt, rule.confirmationMinutes) + ) { + await updateState(pg, rule.id, 'FIRING', now, now); + await logTransition(pg, rule, 'PENDING', 'FIRING', currentValue, previousValue); + await pg.query(psql` + INSERT INTO "metric_alert_incidents" ( + "metric_alert_rule_id", "current_value", "previous_value", "threshold_value" + ) VALUES ( + ${rule.id}, ${currentValue}, ${previousValue}, ${rule.thresholdValue} + ) + `); + logger.info({ ruleId: rule.id }, 'Alert rule entered FIRING state'); + // TODO: Send notifications to all channels + } + break; + } + case 'FIRING': { + break; + } + case 'RECOVERING': { + await updateState(pg, rule.id, 'FIRING', now); + await logTransition(pg, rule, 'RECOVERING', 'FIRING', currentValue, previousValue); + logger.info({ ruleId: rule.id }, 'Alert rule re-entered FIRING from RECOVERING'); + break; + } + } + } else { + switch (rule.state) { + case 'NORMAL': { + break; + } + case 'PENDING': { + await updateState(pg, rule.id, 'NORMAL', now); + await logTransition(pg, rule, 'PENDING', 'NORMAL', currentValue, previousValue); + logger.info( + { ruleId: rule.id }, + 'Alert rule returned to NORMAL from PENDING (false alarm)', + ); + break; + } + case 'FIRING': { + await updateState(pg, rule.id, 'RECOVERING', now); + await logTransition(pg, rule, 'FIRING', 'RECOVERING', currentValue, previousValue); + logger.info({ ruleId: rule.id }, 'Alert rule entered RECOVERING state'); + break; + } + case 'RECOVERING': { + if ( + rule.confirmationMinutes === 0 || + hasElapsed(rule.stateChangedAt, rule.confirmationMinutes) + ) { + await updateState(pg, rule.id, 'NORMAL', now); + await logTransition(pg, rule, 'RECOVERING', 'NORMAL', currentValue, previousValue); + await pg.query(psql` + UPDATE "metric_alert_incidents" + SET "resolved_at" = NOW() + WHERE "metric_alert_rule_id" = ${rule.id} AND "resolved_at" IS NULL + `); + logger.info({ ruleId: rule.id }, 'Alert rule resolved, back to NORMAL'); + // TODO: Send resolved notifications to all channels + } + break; + } + } + } + + await pg.query(psql` + UPDATE "metric_alert_rules" + SET "last_evaluated_at" = NOW(), "updated_at" = NOW() + WHERE "id" = ${rule.id} + `); +} + +async function updateState( + pg: PostgresDatabasePool, + ruleId: string, + state: string, + stateChangedAt: Date, + lastTriggeredAt?: Date, +) { + await pg.query(psql` + UPDATE "metric_alert_rules" + SET + "state" = ${state} + , "state_changed_at" = ${stateChangedAt.toISOString()} + ${lastTriggeredAt ? psql`, "last_triggered_at" = ${lastTriggeredAt.toISOString()}` : psql``} + , "updated_at" = NOW() + WHERE "id" = ${ruleId} + `); +} + +async function logTransition( + pg: PostgresDatabasePool, + rule: MetricAlertRuleRow, + fromState: string, + toState: string, + value: number, + previousValue: number, +) { + // TODO: Look up org plan to determine expires_at (7d hobby/pro, 30d enterprise) + const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); + + await pg.query(psql` + INSERT INTO "metric_alert_state_log" ( + "metric_alert_rule_id" + , "target_id" + , "from_state" + , "to_state" + , "value" + , "previous_value" + , "threshold_value" + , "expires_at" + ) VALUES ( + ${rule.id} + , ${rule.targetId} + , ${fromState} + , ${toState} + , ${value} + , ${previousValue} + , ${rule.thresholdValue} + , ${expiresAt.toISOString()} + ) + `); +} diff --git a/packages/services/workflows/src/tasks/evaluate-metric-alert-rules.ts b/packages/services/workflows/src/tasks/evaluate-metric-alert-rules.ts new file mode 100644 index 0000000000..e0a31b3224 --- /dev/null +++ b/packages/services/workflows/src/tasks/evaluate-metric-alert-rules.ts @@ -0,0 +1,80 @@ +import { z } from 'zod'; +import { psql } from '@hive/postgres'; +import { defineTask, implementTask } from '../kit.js'; +import { + evaluateRule, + fetchEnabledRules, + groupRulesByQuery, + queryClickHouseWindows, +} from '../lib/metric-alert-evaluator.js'; + +export const EvaluateMetricAlertRulesTask = defineTask({ + name: 'evaluateMetricAlertRules', + schema: z.unknown(), +}); + +export const task = implementTask(EvaluateMetricAlertRulesTask, async args => { + const { context, logger } = args; + + if (!context.clickhouse) { + logger.debug('ClickHouse not configured, skipping metric alert evaluation'); + return; + } + + const rules = await fetchEnabledRules(context.pg); + + if (rules.length === 0) { + logger.debug('No enabled metric alert rules found'); + return; + } + + logger.info({ count: rules.length }, 'Evaluating metric alert rules'); + + const groups = groupRulesByQuery(rules); + + for (const [, groupRules] of groups) { + const representative = groupRules[0]; + + let windows; + try { + windows = await queryClickHouseWindows( + context.clickhouse, + representative.targetId, + representative.timeWindowMinutes, + ); + } catch (error) { + logger.error( + { error, targetId: representative.targetId }, + 'Failed to query ClickHouse for alert evaluation', + ); + continue; + } + + if (!windows.current || !windows.previous) { + logger.debug( + { targetId: representative.targetId }, + 'Insufficient data for evaluation (need both current and previous windows)', + ); + for (const rule of groupRules) { + await context.pg.query(psql` + UPDATE "metric_alert_rules" + SET "last_evaluated_at" = NOW(), "updated_at" = NOW() + WHERE "id" = ${rule.id} + `); + } + continue; + } + + for (const rule of groupRules) { + await evaluateRule({ + rule, + current: windows.current, + previous: windows.previous, + pg: context.pg, + logger, + }); + } + } + + logger.info('Metric alert evaluation complete'); +}); diff --git a/packages/services/workflows/src/tasks/purge-expired-alert-state-log.ts b/packages/services/workflows/src/tasks/purge-expired-alert-state-log.ts new file mode 100644 index 0000000000..dde60b57b1 --- /dev/null +++ b/packages/services/workflows/src/tasks/purge-expired-alert-state-log.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; +import { psql } from '@hive/postgres'; +import { defineTask, implementTask } from '../kit.js'; + +export const PurgeExpiredAlertStateLogTask = defineTask({ + name: 'purgeExpiredAlertStateLog', + schema: z.unknown(), +}); + +export const task = implementTask(PurgeExpiredAlertStateLogTask, async args => { + args.logger.debug('purging expired alert state log entries'); + const result = await args.context.pg.oneFirst(psql` + WITH "deleted" AS ( + DELETE FROM "metric_alert_state_log" + WHERE "expires_at" < NOW() + RETURNING 1 + ) + SELECT COUNT(*)::int FROM "deleted"; + `); + const amount = z.number().parse(result); + args.logger.debug( + { purgedCount: amount }, + 'finished purging expired alert state log entries', + ); +}); From 707234e7d7ccdf5700f375cfe3c389103661f051 Mon Sep 17 00:00:00 2001 From: Jonathan Brennan Date: Thu, 16 Apr 2026 20:05:34 -0500 Subject: [PATCH 14/18] add metric alert notification sender for Slack, Webhook, and Teams --- packages/services/workflows/package.json | 1 + .../src/lib/metric-alert-evaluator.ts | 31 +- .../src/lib/metric-alert-notifier.ts | 261 ++++ .../src/tasks/evaluate-metric-alert-rules.ts | 37 + pnpm-lock.yaml | 1234 +++-------------- 5 files changed, 535 insertions(+), 1029 deletions(-) create mode 100644 packages/services/workflows/src/lib/metric-alert-notifier.ts diff --git a/packages/services/workflows/package.json b/packages/services/workflows/package.json index ee208dad92..30f5cba411 100644 --- a/packages/services/workflows/package.json +++ b/packages/services/workflows/package.json @@ -18,6 +18,7 @@ "@hive/service-common": "workspace:*", "@hive/storage": "workspace:*", "@sentry/node": "7.120.2", + "@slack/web-api": "7.10.0", "@trpc/client": "10.45.3", "@types/mjml": "4.7.1", "@types/nodemailer": "7.0.4", diff --git a/packages/services/workflows/src/lib/metric-alert-evaluator.ts b/packages/services/workflows/src/lib/metric-alert-evaluator.ts index 1932337966..0e239cf2b9 100644 --- a/packages/services/workflows/src/lib/metric-alert-evaluator.ts +++ b/packages/services/workflows/src/lib/metric-alert-evaluator.ts @@ -8,12 +8,14 @@ export type MetricAlertRuleRow = { organizationId: string; projectId: string; targetId: string; + name: string; type: 'LATENCY' | 'ERROR_RATE' | 'TRAFFIC'; timeWindowMinutes: number; metric: 'avg' | 'p75' | 'p90' | 'p95' | 'p99' | null; thresholdType: 'FIXED_VALUE' | 'PERCENTAGE_CHANGE'; thresholdValue: number; direction: 'ABOVE' | 'BELOW'; + severity: 'INFO' | 'WARNING' | 'CRITICAL'; state: 'NORMAL' | 'PENDING' | 'FIRING' | 'RECOVERING'; stateChangedAt: string | null; confirmationMinutes: number; @@ -105,12 +107,14 @@ export async function fetchEnabledRules(pg: PostgresDatabasePool): Promise Promise; + export async function evaluateRule(args: { rule: MetricAlertRuleRow; current: ClickHouseWindowRow; previous: ClickHouseWindowRow; pg: PostgresDatabasePool; logger: Logger; + onTransition?: OnStateTransition; }): Promise { - const { rule, current, previous, pg, logger } = args; + const { rule, current, previous, pg, logger, onTransition } = args; const now = new Date(); const currentValue = extractMetricValue(current, rule); @@ -220,7 +233,13 @@ export async function evaluateRule(args: { ) `); logger.info({ ruleId: rule.id }, 'Alert rule entered FIRING state'); - // TODO: Send notifications to all channels + await onTransition?.({ + rule, + fromState: 'PENDING', + toState: 'FIRING', + currentValue, + previousValue, + }); } break; } @@ -267,7 +286,13 @@ export async function evaluateRule(args: { WHERE "metric_alert_rule_id" = ${rule.id} AND "resolved_at" IS NULL `); logger.info({ ruleId: rule.id }, 'Alert rule resolved, back to NORMAL'); - // TODO: Send resolved notifications to all channels + await onTransition?.({ + rule, + fromState: 'RECOVERING', + toState: 'NORMAL', + currentValue, + previousValue, + }); } break; } diff --git a/packages/services/workflows/src/lib/metric-alert-notifier.ts b/packages/services/workflows/src/lib/metric-alert-notifier.ts new file mode 100644 index 0000000000..c371471092 --- /dev/null +++ b/packages/services/workflows/src/lib/metric-alert-notifier.ts @@ -0,0 +1,261 @@ +import got from 'got'; +import type { Logger } from '@graphql-hive/logger'; +import type { PostgresDatabasePool } from '@hive/postgres'; +import { psql } from '@hive/postgres'; +import { WebClient } from '@slack/web-api'; +import type { MetricAlertRuleRow } from './metric-alert-evaluator.js'; +import { sendWebhook, type RequestBroker } from './webhooks/send-webhook.js'; + +type AlertChannelRow = { + id: string; + type: 'SLACK' | 'WEBHOOK' | 'MSTEAMS_WEBHOOK'; + name: string; + slackChannel: string | null; + webhookEndpoint: string | null; +}; + +type NotificationEvent = { + state: 'firing' | 'resolved'; + rule: MetricAlertRuleRow; + currentValue: number; + previousValue: number; + organizationSlug: string; + projectSlug: string; + targetSlug: string; +}; + +export async function sendMetricAlertNotifications(args: { + ruleId: string; + event: NotificationEvent; + pg: PostgresDatabasePool; + requestBroker: RequestBroker | null; + logger: Logger; +}): Promise { + const { ruleId, event, pg, logger } = args; + + // Fetch channels attached to this rule + const channels = (await pg.any(psql` + SELECT + ac."id" + , ac."type" + , ac."name" + , ac."slack_channel" as "slackChannel" + , ac."webhook_endpoint" as "webhookEndpoint" + FROM "alert_channels" ac + INNER JOIN "metric_alert_rule_channels" marc + ON marc."alert_channel_id" = ac."id" + WHERE marc."metric_alert_rule_id" = ${ruleId} + `)) as unknown as AlertChannelRow[]; + + if (channels.length === 0) { + logger.warn({ ruleId }, 'No channels configured for metric alert rule'); + return; + } + + for (const channel of channels) { + try { + switch (channel.type) { + case 'SLACK': { + await sendSlackNotification({ channel, event, pg, logger }); + break; + } + case 'WEBHOOK': { + await sendWebhookNotification({ + channel, + event, + requestBroker: args.requestBroker, + logger, + }); + break; + } + case 'MSTEAMS_WEBHOOK': { + await sendTeamsNotification({ channel, event, logger }); + break; + } + } + } catch (error) { + logger.error( + { error, channelId: channel.id, channelType: channel.type }, + 'Failed to send metric alert notification', + ); + } + } +} + +async function sendSlackNotification(args: { + channel: AlertChannelRow; + event: NotificationEvent; + pg: PostgresDatabasePool; + logger: Logger; +}) { + const { channel, event, pg, logger } = args; + + if (!channel.slackChannel) { + logger.warn({ channelId: channel.id }, 'Slack channel name not configured'); + return; + } + + // Fetch the org's Slack token + const tokenResult = await pg.maybeOneFirst(psql` + SELECT "slack_token" + FROM "organizations" + WHERE "id" = ${event.rule.organizationId} + `); + + if (!tokenResult) { + logger.warn( + { organizationId: event.rule.organizationId }, + 'Slack integration not configured for organization', + ); + return; + } + + const token = tokenResult as string; + const client = new WebClient(token); + + const isFiring = event.state === 'firing'; + const emoji = isFiring ? ':rotating_light:' : ':white_check_mark:'; + const action = isFiring ? 'triggered' : 'resolved'; + const color = isFiring ? '#E74C3C' : '#2ECC71'; + + const changeText = formatChangeText(event); + + await client.chat.postMessage({ + channel: channel.slackChannel, + text: `${emoji} Metric alert ${action}: "${event.rule.name}"`, + attachments: [ + { + color, + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: [ + `*${event.rule.name}* — ${action}`, + `Type: ${event.rule.type} | Severity: ${event.rule.severity}`, + changeText, + `Target: \`${event.targetSlug}\` in \`${event.projectSlug}\``, + ].join('\n'), + }, + }, + ], + }, + ], + }); + + logger.debug({ channelId: channel.id }, 'Slack notification sent'); +} + +async function sendWebhookNotification(args: { + channel: AlertChannelRow; + event: NotificationEvent; + requestBroker: RequestBroker | null; + logger: Logger; +}) { + const { channel, event, logger } = args; + + if (!channel.webhookEndpoint) { + logger.warn({ channelId: channel.id }, 'Webhook endpoint not configured'); + return; + } + + const payload = buildWebhookPayload(event); + + await sendWebhook(logger, args.requestBroker, { + attempt: 0, + maxAttempts: 5, + endpoint: channel.webhookEndpoint, + data: payload, + }); + + logger.debug({ channelId: channel.id }, 'Webhook notification sent'); +} + +async function sendTeamsNotification(args: { + channel: AlertChannelRow; + event: NotificationEvent; + logger: Logger; +}) { + const { channel, event, logger } = args; + + if (!channel.webhookEndpoint) { + logger.warn({ channelId: channel.id }, 'Teams webhook endpoint not configured'); + return; + } + + const isFiring = event.state === 'firing'; + const emoji = isFiring ? '🔴' : '✅'; + const action = isFiring ? 'triggered' : 'resolved'; + const themeColor = isFiring ? 'E74C3C' : '2ECC71'; + + const changeText = formatChangeText(event); + + const card = { + '@type': 'MessageCard', + '@context': 'http://schema.org/extensions', + themeColor, + summary: `Metric alert ${action}: "${event.rule.name}"`, + sections: [ + { + activityTitle: `${emoji} ${event.rule.name} — ${action}`, + facts: [ + { name: 'Type', value: event.rule.type }, + { name: 'Severity', value: event.rule.severity }, + { name: 'Target', value: `${event.targetSlug} in ${event.projectSlug}` }, + ], + text: changeText, + }, + ], + }; + + await got.post(channel.webhookEndpoint, { + json: card, + timeout: { request: 10_000 }, + }); + + logger.debug({ channelId: channel.id }, 'Teams notification sent'); +} + +function formatChangeText(event: NotificationEvent): string { + const { rule, currentValue, previousValue } = event; + const unit = rule.type === 'LATENCY' ? 'ms' : rule.type === 'ERROR_RATE' ? '%' : ' requests'; + const metricLabel = + rule.type === 'LATENCY' ? `${rule.metric} latency` : rule.type === 'ERROR_RATE' ? 'Error rate' : 'Traffic'; + + if (event.state === 'firing') { + const changePercent = + previousValue !== 0 ? (((currentValue - previousValue) / previousValue) * 100).toFixed(1) : 'N/A'; + return `${metricLabel}: **${currentValue.toFixed(2)}${unit}** (was ${previousValue.toFixed(2)}${unit}, ${changePercent}% change) — Threshold: ${rule.direction.toLowerCase()} ${rule.thresholdValue}${rule.thresholdType === 'PERCENTAGE_CHANGE' ? '%' : unit}`; + } + + return `${metricLabel}: **${currentValue.toFixed(2)}${unit}** (threshold: ${rule.thresholdValue}${rule.thresholdType === 'PERCENTAGE_CHANGE' ? '%' : unit})`; +} + +function buildWebhookPayload(event: NotificationEvent) { + const { rule, currentValue, previousValue } = event; + const changePercent = + previousValue !== 0 ? ((currentValue - previousValue) / previousValue) * 100 : null; + + return { + type: 'metric_alert', + state: event.state, + alert: { + name: rule.name, + type: rule.type, + metric: rule.metric, + severity: rule.severity, + }, + currentValue, + previousValue, + changePercent, + threshold: { + type: rule.thresholdType, + value: rule.thresholdValue, + direction: rule.direction, + }, + target: { slug: event.targetSlug }, + project: { slug: event.projectSlug }, + organization: { slug: event.organizationSlug }, + }; +} diff --git a/packages/services/workflows/src/tasks/evaluate-metric-alert-rules.ts b/packages/services/workflows/src/tasks/evaluate-metric-alert-rules.ts index e0a31b3224..a3e7c09652 100644 --- a/packages/services/workflows/src/tasks/evaluate-metric-alert-rules.ts +++ b/packages/services/workflows/src/tasks/evaluate-metric-alert-rules.ts @@ -7,6 +7,7 @@ import { groupRulesByQuery, queryClickHouseWindows, } from '../lib/metric-alert-evaluator.js'; +import { sendMetricAlertNotifications } from '../lib/metric-alert-notifier.js'; export const EvaluateMetricAlertRulesTask = defineTask({ name: 'evaluateMetricAlertRules', @@ -65,6 +66,18 @@ export const task = implementTask(EvaluateMetricAlertRulesTask, async args => { continue; } + // Fetch slugs for notification messages (once per group since all share a target) + const slugs = (await context.pg.maybeOne(psql` + SELECT + o."slug" as "organizationSlug" + , p."slug" as "projectSlug" + , t."slug" as "targetSlug" + FROM "targets" t + INNER JOIN "projects" p ON p."id" = t."project_id" + INNER JOIN "organizations" o ON o."id" = p."org_id" + WHERE t."id" = ${representative.targetId} + `)) as { organizationSlug: string; projectSlug: string; targetSlug: string } | null; + for (const rule of groupRules) { await evaluateRule({ rule, @@ -72,6 +85,30 @@ export const task = implementTask(EvaluateMetricAlertRulesTask, async args => { previous: windows.previous, pg: context.pg, logger, + onTransition: async ({ rule: r, fromState, toState, currentValue, previousValue }) => { + if (!slugs) return; + + const isFiring = toState === 'FIRING'; + const isResolved = fromState === 'RECOVERING' && toState === 'NORMAL'; + + if (isFiring || isResolved) { + await sendMetricAlertNotifications({ + ruleId: r.id, + event: { + state: isFiring ? 'firing' : 'resolved', + rule: r, + currentValue, + previousValue, + organizationSlug: slugs.organizationSlug, + projectSlug: slugs.projectSlug, + targetSlug: slugs.targetSlug, + }, + pg: context.pg, + requestBroker: context.requestBroker, + logger, + }); + } + }, }); } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee292725c3..7b804212d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -290,7 +290,7 @@ importers: devDependencies: '@graphql-hive/gateway': specifier: ^2.1.19 - version: 2.1.19(@types/ioredis-mock@8.2.5)(@types/node@24.12.2)(encoding@0.1.13)(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0)(prom-client@15.1.3) + version: 2.1.19(@types/ioredis-mock@8.2.5)(@types/node@24.12.2)(encoding@0.1.13)(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0)(prom-client@15.1.3) '@types/js-yaml': specifier: 4.0.9 version: 4.0.9 @@ -864,7 +864,7 @@ importers: version: 0.52.2 monaco-graphql: specifier: ^1.7.3 - version: 1.7.3(graphql@16.12.0)(monaco-editor@0.52.2)(prettier@3.8.1) + version: 1.7.3(graphql@16.12.0)(monaco-editor@0.52.2)(prettier@3.4.2) monacopilot: specifier: ^1.2.12 version: 1.2.12(monaco-editor@0.52.2) @@ -954,7 +954,7 @@ importers: version: 0.16.6(typescript@5.7.3) graphql-yoga: specifier: 5.13.3 - version: 5.13.3(graphql@16.12.0) + version: 5.13.3(graphql@16.9.0) ioredis: specifier: ^5.0.0 version: 5.8.2 @@ -973,7 +973,7 @@ importers: version: link:../laboratory graphql-yoga: specifier: 5.13.3 - version: 5.13.3(graphql@16.12.0) + version: 5.13.3(graphql@16.9.0) publishDirectory: dist packages/libraries/yoga: @@ -1069,7 +1069,7 @@ importers: version: 6.2.0 pg-promise: specifier: 11.10.2 - version: 11.10.2(pg-query-stream@4.14.0(pg@8.20.0)) + version: 11.10.2(pg-query-stream@4.14.0(pg@8.13.1)) tslib: specifier: 2.8.1 version: 2.8.1 @@ -1452,7 +1452,7 @@ importers: devDependencies: '@graphql-eslint/eslint-plugin': specifier: 3.20.1 - version: 3.20.1(patch_hash=09fda5b278e2d5231c4881c9d877a8b38b813c049a8e6651f7dfb9df1da7f170)(@babel/core@7.28.5)(@types/node@25.5.0)(cosmiconfig-toml-loader@1.0.0)(encoding@0.1.13)(graphql@16.9.0) + version: 3.20.1(patch_hash=09fda5b278e2d5231c4881c9d877a8b38b813c049a8e6651f7dfb9df1da7f170)(@babel/core@7.28.5)(@types/node@24.12.2)(cosmiconfig-toml-loader@1.0.0)(encoding@0.1.13)(graphql@16.9.0) '@hive/service-common': specifier: workspace:* version: link:../service-common @@ -1711,7 +1711,7 @@ importers: version: 1.0.9(pino@10.3.0) '@graphql-hive/plugin-opentelemetry': specifier: 1.3.0 - version: 1.3.0(encoding@0.1.13)(graphql@16.12.0)(pino@10.3.0)(ws@8.18.0) + version: 1.3.0(encoding@0.1.13)(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0)(ws@8.18.0) '@opentelemetry/api': specifier: 1.9.0 version: 1.9.0 @@ -1813,7 +1813,7 @@ importers: version: 16.9.0 pg-promise: specifier: 11.10.2 - version: 11.10.2(pg-query-stream@4.14.0(pg@8.20.0)) + version: 11.10.2(pg-query-stream@4.14.0(pg@8.13.1)) tslib: specifier: 2.8.1 version: 2.8.1 @@ -2024,6 +2024,9 @@ importers: '@sentry/node': specifier: 7.120.2 version: 7.120.2 + '@slack/web-api': + specifier: 7.10.0 + version: 7.10.0 '@trpc/client': specifier: 10.45.3 version: 10.45.3(@trpc/server@10.45.3) @@ -2395,7 +2398,7 @@ importers: version: 0.52.2 monaco-graphql: specifier: ^1.7.2 - version: 1.7.3(graphql@16.9.0)(monaco-editor@0.52.2)(prettier@3.8.1) + version: 1.7.3(graphql@16.9.0)(monaco-editor@0.52.2)(prettier@3.4.2) monaco-themes: specifier: 0.4.4 version: 0.4.4 @@ -13133,10 +13136,6 @@ packages: resolution: {integrity: sha512-f0cRzm6dkyVYV3nPoooP8XlccPQukegwhAnpoLcXy+X+A8KfpGOoXwDr9FLZd3wzgLaBGQBE3lY93Zm/i1JvIQ==} engines: {node: '>= 6'} - form-data@4.0.4: - resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} - engines: {node: '>= 6'} - form-data@4.0.5: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} @@ -19406,14 +19405,6 @@ snapshots: '@apollo/utils.logger': 2.0.0 graphql: 16.9.0 - '@apollo/server-gateway-interface@2.0.0(graphql@16.12.0)': - dependencies: - '@apollo/usage-reporting-protobuf': 4.1.1 - '@apollo/utils.fetcher': 3.1.0 - '@apollo/utils.keyvaluecache': 4.0.0 - '@apollo/utils.logger': 3.0.0 - graphql: 16.12.0 - '@apollo/server-gateway-interface@2.0.0(graphql@16.9.0)': dependencies: '@apollo/usage-reporting-protobuf': 4.1.1 @@ -19474,10 +19465,6 @@ snapshots: '@apollo/utils.isnodelike': 3.0.0 sha.js: 2.4.11 - '@apollo/utils.dropunuseddefinitions@2.0.1(graphql@16.12.0)': - dependencies: - graphql: 16.12.0 - '@apollo/utils.dropunuseddefinitions@2.0.1(graphql@16.9.0)': dependencies: graphql: 16.9.0 @@ -19511,50 +19498,23 @@ snapshots: '@apollo/utils.logger@3.0.0': {} - '@apollo/utils.printwithreducedwhitespace@2.0.1(graphql@16.12.0)': - dependencies: - graphql: 16.12.0 - '@apollo/utils.printwithreducedwhitespace@2.0.1(graphql@16.9.0)': dependencies: graphql: 16.9.0 - '@apollo/utils.removealiases@2.0.1(graphql@16.12.0)': - dependencies: - graphql: 16.12.0 - '@apollo/utils.removealiases@2.0.1(graphql@16.9.0)': dependencies: graphql: 16.9.0 - '@apollo/utils.sortast@2.0.1(graphql@16.12.0)': - dependencies: - graphql: 16.12.0 - lodash.sortby: 4.7.0 - '@apollo/utils.sortast@2.0.1(graphql@16.9.0)': dependencies: graphql: 16.9.0 lodash.sortby: 4.7.0 - '@apollo/utils.stripsensitiveliterals@2.0.1(graphql@16.12.0)': - dependencies: - graphql: 16.12.0 - '@apollo/utils.stripsensitiveliterals@2.0.1(graphql@16.9.0)': dependencies: graphql: 16.9.0 - '@apollo/utils.usagereporting@2.1.0(graphql@16.12.0)': - dependencies: - '@apollo/usage-reporting-protobuf': 4.1.1 - '@apollo/utils.dropunuseddefinitions': 2.0.1(graphql@16.12.0) - '@apollo/utils.printwithreducedwhitespace': 2.0.1(graphql@16.12.0) - '@apollo/utils.removealiases': 2.0.1(graphql@16.12.0) - '@apollo/utils.sortast': 2.0.1(graphql@16.12.0) - '@apollo/utils.stripsensitiveliterals': 2.0.1(graphql@16.12.0) - graphql: 16.12.0 - '@apollo/utils.usagereporting@2.1.0(graphql@16.9.0)': dependencies: '@apollo/usage-reporting-protobuf': 4.1.1 @@ -21625,25 +21585,12 @@ snapshots: '@whatwg-node/promise-helpers': 1.3.2 tslib: 2.8.1 - '@envelop/disable-introspection@9.0.0(@envelop/core@5.5.1)(graphql@16.12.0)': - dependencies: - '@envelop/core': 5.5.1 - graphql: 16.12.0 - tslib: 2.8.1 - '@envelop/disable-introspection@9.0.0(@envelop/core@5.5.1)(graphql@16.9.0)': dependencies: '@envelop/core': 5.5.1 graphql: 16.9.0 tslib: 2.8.1 - '@envelop/extended-validation@7.0.0(@envelop/core@5.5.1)(graphql@16.12.0)': - dependencies: - '@envelop/core': 5.5.1 - '@graphql-tools/utils': 10.9.1(graphql@16.12.0) - graphql: 16.12.0 - tslib: 2.8.1 - '@envelop/extended-validation@7.0.0(@envelop/core@5.5.1)(graphql@16.9.0)': dependencies: '@envelop/core': 5.5.1 @@ -21651,16 +21598,6 @@ snapshots: graphql: 16.9.0 tslib: 2.8.1 - '@envelop/generic-auth@11.0.0(@envelop/core@5.5.1)(graphql@16.12.0)': - dependencies: - '@envelop/core': 5.5.1 - '@envelop/extended-validation': 7.0.0(@envelop/core@5.5.1)(graphql@16.12.0) - '@graphql-tools/executor': 1.5.0(graphql@16.12.0) - '@graphql-tools/utils': 10.9.1(graphql@16.12.0) - '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 - tslib: 2.8.1 - '@envelop/generic-auth@11.0.0(@envelop/core@5.5.1)(graphql@16.9.0)': dependencies: '@envelop/core': 5.5.1 @@ -21696,12 +21633,6 @@ snapshots: '@envelop/core': 5.5.1 graphql: 16.9.0 - '@envelop/on-resolve@7.0.0(@envelop/core@5.5.1)(graphql@16.12.0)': - dependencies: - '@envelop/core': 5.5.1 - '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 - '@envelop/on-resolve@7.0.0(@envelop/core@5.5.1)(graphql@16.9.0)': dependencies: '@envelop/core': 5.5.1 @@ -21717,22 +21648,22 @@ snapshots: graphql: 16.9.0 tslib: 2.8.1 - '@envelop/prometheus@14.0.0(@envelop/core@5.5.1)(graphql@16.12.0)(prom-client@15.1.3)': + '@envelop/prometheus@14.0.0(@envelop/core@5.5.1)(graphql@16.9.0)(prom-client@15.1.3)': dependencies: '@envelop/core': 5.5.1 - '@envelop/on-resolve': 7.0.0(@envelop/core@5.5.1)(graphql@16.12.0) - graphql: 16.12.0 + '@envelop/on-resolve': 7.0.0(@envelop/core@5.5.1)(graphql@16.9.0) + graphql: 16.9.0 prom-client: 15.1.3 tslib: 2.8.1 - '@envelop/rate-limiter@9.0.0(@envelop/core@5.5.1)(graphql@16.12.0)': + '@envelop/rate-limiter@9.0.0(@envelop/core@5.5.1)(graphql@16.9.0)': dependencies: '@envelop/core': 5.5.1 - '@envelop/on-resolve': 7.0.0(@envelop/core@5.5.1)(graphql@16.12.0) - '@graphql-tools/utils': 10.9.1(graphql@16.12.0) + '@envelop/on-resolve': 7.0.0(@envelop/core@5.5.1)(graphql@16.9.0) + '@graphql-tools/utils': 10.9.1(graphql@16.9.0) '@types/picomatch': 4.0.2 '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 + graphql: 16.9.0 lodash.get: 4.4.2 ms: 2.1.3 picomatch: 4.0.4 @@ -21748,17 +21679,6 @@ snapshots: lru-cache: 10.2.0 tslib: 2.8.1 - '@envelop/response-cache@7.1.3(@envelop/core@5.5.1)(graphql@16.12.0)': - dependencies: - '@envelop/core': 5.5.1 - '@graphql-tools/utils': 10.9.1(graphql@16.12.0) - '@whatwg-node/fetch': 0.10.13 - '@whatwg-node/promise-helpers': 1.3.2 - fast-json-stable-stringify: 2.1.0 - graphql: 16.12.0 - lru-cache: 10.2.0 - tslib: 2.8.1 - '@envelop/response-cache@7.1.3(@envelop/core@5.5.1)(graphql@16.9.0)': dependencies: '@envelop/core': 5.5.1 @@ -21770,17 +21690,6 @@ snapshots: lru-cache: 10.2.0 tslib: 2.8.1 - '@envelop/response-cache@9.0.0(@envelop/core@5.5.1)(graphql@16.12.0)': - dependencies: - '@envelop/core': 5.5.1 - '@graphql-tools/utils': 10.9.1(graphql@16.12.0) - '@whatwg-node/fetch': 0.10.13 - '@whatwg-node/promise-helpers': 1.3.2 - fast-json-stable-stringify: 2.1.0 - graphql: 16.12.0 - lru-cache: 11.0.2 - tslib: 2.8.1 - '@envelop/response-cache@9.0.0(@envelop/core@5.5.1)(graphql@16.9.0)': dependencies: '@envelop/core': 5.5.1 @@ -22451,46 +22360,6 @@ snapshots: - supports-color - utf-8-validate - '@graphql-eslint/eslint-plugin@3.20.1(patch_hash=09fda5b278e2d5231c4881c9d877a8b38b813c049a8e6651f7dfb9df1da7f170)(@babel/core@7.28.5)(@types/node@25.5.0)(cosmiconfig-toml-loader@1.0.0)(encoding@0.1.13)(graphql@16.9.0)': - dependencies: - '@babel/code-frame': 7.29.0 - '@graphql-tools/code-file-loader': 7.3.23(@babel/core@7.28.5)(graphql@16.9.0) - '@graphql-tools/graphql-tag-pluck': 7.5.2(@babel/core@7.28.5)(graphql@16.9.0) - '@graphql-tools/utils': 9.2.1(graphql@16.9.0) - chalk: 4.1.2 - debug: 4.4.3(supports-color@8.1.1) - fast-glob: 3.3.3 - graphql: 16.9.0 - graphql-config: 4.5.0(@types/node@25.5.0)(cosmiconfig-toml-loader@1.0.0)(encoding@0.1.13)(graphql@16.9.0) - graphql-depth-limit: 1.1.0(graphql@16.9.0) - lodash.lowercase: 4.3.0 - tslib: 2.8.1 - transitivePeerDependencies: - - '@babel/core' - - '@types/node' - - bufferutil - - cosmiconfig-toml-loader - - encoding - - supports-color - - utf-8-validate - - '@graphql-hive/core@0.18.0(graphql@16.12.0)(pino@10.3.0)': - dependencies: - '@graphql-hive/logger': 1.0.9(pino@10.3.0) - '@graphql-hive/signal': 2.0.0 - '@graphql-tools/utils': 10.9.1(graphql@16.12.0) - '@whatwg-node/fetch': 0.10.13 - async-retry: 1.3.3 - events: 3.3.0 - graphql: 16.12.0 - js-md5: 0.8.3 - lodash.sortby: 4.7.0 - tiny-lru: 8.0.2 - transitivePeerDependencies: - - '@logtape/logtape' - - pino - - winston - '@graphql-hive/core@0.18.0(graphql@16.9.0)(pino@10.3.0)': dependencies: '@graphql-hive/logger': 1.0.9(pino@10.3.0) @@ -22508,106 +22377,6 @@ snapshots: - pino - winston - '@graphql-hive/gateway-runtime@2.5.0(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0)(ws@8.18.0)': - dependencies: - '@envelop/core': 5.5.1 - '@envelop/disable-introspection': 9.0.0(@envelop/core@5.5.1)(graphql@16.12.0) - '@envelop/generic-auth': 11.0.0(@envelop/core@5.5.1)(graphql@16.12.0) - '@envelop/instrumentation': 1.0.0 - '@graphql-hive/core': 0.18.0(graphql@16.12.0)(pino@10.3.0) - '@graphql-hive/logger': 1.0.9(pino@10.3.0) - '@graphql-hive/pubsub': 2.1.1(ioredis@5.8.2) - '@graphql-hive/signal': 2.0.0 - '@graphql-hive/yoga': 0.46.0(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0)(pino@10.3.0) - '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.12.0) - '@graphql-mesh/fusion-runtime': 1.6.2(@types/node@25.5.0)(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0) - '@graphql-mesh/hmac-upstream-signature': 2.0.8(graphql@16.12.0)(ioredis@5.8.2) - '@graphql-mesh/plugin-response-cache': 0.104.18(graphql@16.12.0)(ioredis@5.8.2) - '@graphql-mesh/transport-common': 1.0.12(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0) - '@graphql-mesh/types': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) - '@graphql-mesh/utils': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) - '@graphql-tools/batch-delegate': 10.0.8(graphql@16.12.0) - '@graphql-tools/delegate': 12.0.2(graphql@16.12.0) - '@graphql-tools/executor-common': 1.0.5(graphql@16.12.0) - '@graphql-tools/executor-http': 3.0.7(@types/node@25.5.0)(graphql@16.12.0) - '@graphql-tools/federation': 4.2.6(@types/node@25.5.0)(graphql@16.12.0) - '@graphql-tools/stitch': 10.1.6(graphql@16.12.0) - '@graphql-tools/utils': 10.11.0(graphql@16.12.0) - '@graphql-tools/wrap': 11.1.2(graphql@16.12.0) - '@graphql-yoga/plugin-apollo-usage-report': 0.12.1(@envelop/core@5.5.1)(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0) - '@graphql-yoga/plugin-csrf-prevention': 3.16.2(graphql-yoga@5.17.1(graphql@16.12.0)) - '@graphql-yoga/plugin-defer-stream': 3.16.2(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0) - '@graphql-yoga/plugin-persisted-operations': 3.16.2(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0) - '@types/node': 25.5.0 - '@whatwg-node/disposablestack': 0.0.6 - '@whatwg-node/promise-helpers': 1.3.2 - '@whatwg-node/server': 0.10.17 - '@whatwg-node/server-plugin-cookies': 1.0.5 - graphql: 16.12.0 - graphql-ws: 6.0.6(graphql@16.12.0)(ws@8.18.0) - graphql-yoga: 5.17.1(graphql@16.12.0) - tslib: 2.8.1 - transitivePeerDependencies: - - '@fastify/websocket' - - '@logtape/logtape' - - '@nats-io/nats-core' - - crossws - - ioredis - - pino - - uWebSockets.js - - winston - - ws - - '@graphql-hive/gateway-runtime@2.5.0(graphql@16.12.0)(pino@10.3.0)(ws@8.18.0)': - dependencies: - '@envelop/core': 5.5.1 - '@envelop/disable-introspection': 9.0.0(@envelop/core@5.5.1)(graphql@16.12.0) - '@envelop/generic-auth': 11.0.0(@envelop/core@5.5.1)(graphql@16.12.0) - '@envelop/instrumentation': 1.0.0 - '@graphql-hive/core': 0.18.0(graphql@16.12.0)(pino@10.3.0) - '@graphql-hive/logger': 1.0.9(pino@10.3.0) - '@graphql-hive/pubsub': 2.1.1(ioredis@5.8.2) - '@graphql-hive/signal': 2.0.0 - '@graphql-hive/yoga': 0.46.0(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0)(pino@10.3.0) - '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.12.0) - '@graphql-mesh/fusion-runtime': 1.6.2(@types/node@25.5.0)(graphql@16.12.0)(pino@10.3.0) - '@graphql-mesh/hmac-upstream-signature': 2.0.8(graphql@16.12.0) - '@graphql-mesh/plugin-response-cache': 0.104.18(graphql@16.12.0) - '@graphql-mesh/transport-common': 1.0.12(graphql@16.12.0)(pino@10.3.0) - '@graphql-mesh/types': 0.104.16(graphql@16.12.0) - '@graphql-mesh/utils': 0.104.16(graphql@16.12.0) - '@graphql-tools/batch-delegate': 10.0.8(graphql@16.12.0) - '@graphql-tools/delegate': 12.0.2(graphql@16.12.0) - '@graphql-tools/executor-common': 1.0.5(graphql@16.12.0) - '@graphql-tools/executor-http': 3.0.7(@types/node@25.5.0)(graphql@16.12.0) - '@graphql-tools/federation': 4.2.6(@types/node@25.5.0)(graphql@16.12.0) - '@graphql-tools/stitch': 10.1.6(graphql@16.12.0) - '@graphql-tools/utils': 10.11.0(graphql@16.12.0) - '@graphql-tools/wrap': 11.1.2(graphql@16.12.0) - '@graphql-yoga/plugin-apollo-usage-report': 0.12.1(@envelop/core@5.5.1)(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0) - '@graphql-yoga/plugin-csrf-prevention': 3.16.2(graphql-yoga@5.17.1(graphql@16.12.0)) - '@graphql-yoga/plugin-defer-stream': 3.16.2(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0) - '@graphql-yoga/plugin-persisted-operations': 3.16.2(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0) - '@types/node': 25.5.0 - '@whatwg-node/disposablestack': 0.0.6 - '@whatwg-node/promise-helpers': 1.3.2 - '@whatwg-node/server': 0.10.17 - '@whatwg-node/server-plugin-cookies': 1.0.5 - graphql: 16.12.0 - graphql-ws: 6.0.6(graphql@16.12.0)(ws@8.18.0) - graphql-yoga: 5.17.1(graphql@16.12.0) - tslib: 2.8.1 - transitivePeerDependencies: - - '@fastify/websocket' - - '@logtape/logtape' - - '@nats-io/nats-core' - - crossws - - ioredis - - pino - - uWebSockets.js - - winston - - ws - '@graphql-hive/gateway-runtime@2.5.0(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0)(ws@8.18.0)': dependencies: '@envelop/core': 5.5.1 @@ -22658,41 +22427,41 @@ snapshots: - winston - ws - '@graphql-hive/gateway@2.1.19(@types/ioredis-mock@8.2.5)(@types/node@24.12.2)(encoding@0.1.13)(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0)(prom-client@15.1.3)': + '@graphql-hive/gateway@2.1.19(@types/ioredis-mock@8.2.5)(@types/node@24.12.2)(encoding@0.1.13)(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0)(prom-client@15.1.3)': dependencies: '@commander-js/extra-typings': 14.0.0(commander@14.0.2) '@envelop/core': 5.5.1 '@escape.tech/graphql-armor-block-field-suggestions': 3.0.1 '@escape.tech/graphql-armor-max-depth': 2.4.2 '@escape.tech/graphql-armor-max-tokens': 2.5.1 - '@graphql-hive/gateway-runtime': 2.5.0(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0)(ws@8.18.0) + '@graphql-hive/gateway-runtime': 2.5.0(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0)(ws@8.18.0) '@graphql-hive/importer': 2.0.0 '@graphql-hive/logger': 1.0.9(pino@10.3.0) - '@graphql-hive/plugin-aws-sigv4': 2.0.17(@types/node@24.12.2)(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0) - '@graphql-hive/plugin-opentelemetry': 1.3.0(encoding@0.1.13)(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0)(ws@8.18.0) + '@graphql-hive/plugin-aws-sigv4': 2.0.17(@types/node@24.12.2)(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0) + '@graphql-hive/plugin-opentelemetry': 1.3.0(encoding@0.1.13)(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0)(ws@8.18.0) '@graphql-hive/pubsub': 2.1.1(ioredis@5.8.2) - '@graphql-mesh/cache-cfw-kv': 0.105.16(graphql@16.12.0)(ioredis@5.8.2) - '@graphql-mesh/cache-localforage': 0.105.17(graphql@16.12.0)(ioredis@5.8.2) - '@graphql-mesh/cache-redis': 0.105.2(@types/ioredis-mock@8.2.5)(graphql@16.12.0) - '@graphql-mesh/cache-upstash-redis': 0.1.16(graphql@16.12.0)(ioredis@5.8.2) - '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.12.0) - '@graphql-mesh/hmac-upstream-signature': 2.0.8(graphql@16.12.0)(ioredis@5.8.2) - '@graphql-mesh/plugin-http-cache': 0.105.17(graphql@16.12.0)(ioredis@5.8.2) - '@graphql-mesh/plugin-jit': 0.2.16(graphql@16.12.0)(ioredis@5.8.2) - '@graphql-mesh/plugin-jwt-auth': 2.0.9(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0)(ioredis@5.8.2) - '@graphql-mesh/plugin-prometheus': 2.1.5(@envelop/core@5.5.1)(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0)(prom-client@15.1.3)(ws@8.18.0) - '@graphql-mesh/plugin-rate-limit': 0.105.5(@envelop/core@5.5.1)(graphql@16.12.0)(ioredis@5.8.2) - '@graphql-mesh/plugin-snapshot': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) - '@graphql-mesh/transport-http': 1.0.12(@types/node@24.12.2)(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0) - '@graphql-mesh/transport-http-callback': 1.0.12(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0) - '@graphql-mesh/transport-ws': 2.0.12(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0) - '@graphql-mesh/types': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) - '@graphql-mesh/utils': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) - '@graphql-tools/code-file-loader': 8.1.26(graphql@16.12.0) - '@graphql-tools/graphql-file-loader': 8.1.7(graphql@16.12.0) - '@graphql-tools/load': 8.1.6(graphql@16.12.0) - '@graphql-tools/utils': 10.11.0(graphql@16.12.0) - '@graphql-yoga/render-graphiql': 5.16.2(graphql-yoga@5.17.1(graphql@16.12.0)) + '@graphql-mesh/cache-cfw-kv': 0.105.16(graphql@16.9.0)(ioredis@5.8.2) + '@graphql-mesh/cache-localforage': 0.105.17(graphql@16.9.0)(ioredis@5.8.2) + '@graphql-mesh/cache-redis': 0.105.2(@types/ioredis-mock@8.2.5)(graphql@16.9.0) + '@graphql-mesh/cache-upstash-redis': 0.1.16(graphql@16.9.0)(ioredis@5.8.2) + '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.9.0) + '@graphql-mesh/hmac-upstream-signature': 2.0.8(graphql@16.9.0)(ioredis@5.8.2) + '@graphql-mesh/plugin-http-cache': 0.105.17(graphql@16.9.0)(ioredis@5.8.2) + '@graphql-mesh/plugin-jit': 0.2.16(graphql@16.9.0)(ioredis@5.8.2) + '@graphql-mesh/plugin-jwt-auth': 2.0.9(graphql-yoga@5.17.1(graphql@16.9.0))(graphql@16.9.0)(ioredis@5.8.2) + '@graphql-mesh/plugin-prometheus': 2.1.5(@envelop/core@5.5.1)(graphql-yoga@5.17.1(graphql@16.9.0))(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0)(prom-client@15.1.3)(ws@8.18.0) + '@graphql-mesh/plugin-rate-limit': 0.105.5(@envelop/core@5.5.1)(graphql@16.9.0)(ioredis@5.8.2) + '@graphql-mesh/plugin-snapshot': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) + '@graphql-mesh/transport-http': 1.0.12(@types/node@24.12.2)(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0) + '@graphql-mesh/transport-http-callback': 1.0.12(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0) + '@graphql-mesh/transport-ws': 2.0.12(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0) + '@graphql-mesh/types': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) + '@graphql-mesh/utils': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) + '@graphql-tools/code-file-loader': 8.1.26(graphql@16.9.0) + '@graphql-tools/graphql-file-loader': 8.1.7(graphql@16.9.0) + '@graphql-tools/load': 8.1.6(graphql@16.9.0) + '@graphql-tools/utils': 10.11.0(graphql@16.9.0) + '@graphql-yoga/render-graphiql': 5.16.2(graphql-yoga@5.17.1(graphql@16.9.0)) '@opentelemetry/api': 1.9.0 '@opentelemetry/api-logs': 0.208.0 '@opentelemetry/context-async-hooks': 2.2.0(@opentelemetry/api@1.9.0) @@ -22708,9 +22477,9 @@ snapshots: '@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0) commander: 14.0.2 dotenv: 17.2.3 - graphql: 16.12.0 - graphql-ws: 6.0.6(graphql@16.12.0)(ws@8.18.0) - graphql-yoga: 5.17.1(graphql@16.12.0) + graphql: 16.9.0 + graphql-ws: 6.0.6(graphql@16.9.0)(ws@8.18.0) + graphql-yoga: 5.17.1(graphql@16.9.0) tslib: 2.8.1 ws: 8.18.0 transitivePeerDependencies: @@ -22737,13 +22506,13 @@ snapshots: optionalDependencies: pino: 10.3.0 - '@graphql-hive/plugin-aws-sigv4@2.0.17(@types/node@24.12.2)(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0)': + '@graphql-hive/plugin-aws-sigv4@2.0.17(@types/node@24.12.2)(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0)': dependencies: '@aws-sdk/client-sts': 3.939.0 - '@graphql-mesh/fusion-runtime': 1.6.2(@types/node@24.12.2)(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0) + '@graphql-mesh/fusion-runtime': 1.6.2(@types/node@24.12.2)(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0) '@whatwg-node/promise-helpers': 1.3.2 aws4: 1.13.2 - graphql: 16.12.0 + graphql: 16.9.0 tslib: 2.8.1 transitivePeerDependencies: - '@logtape/logtape' @@ -22754,84 +22523,6 @@ snapshots: - pino - winston - '@graphql-hive/plugin-opentelemetry@1.3.0(encoding@0.1.13)(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0)(ws@8.18.0)': - dependencies: - '@graphql-hive/core': 0.18.0(graphql@16.12.0)(pino@10.3.0) - '@graphql-hive/gateway-runtime': 2.5.0(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0)(ws@8.18.0) - '@graphql-hive/logger': 1.0.9(pino@10.3.0) - '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.12.0) - '@graphql-mesh/transport-common': 1.0.12(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0) - '@graphql-mesh/types': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) - '@graphql-mesh/utils': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) - '@graphql-tools/utils': 10.11.0(graphql@16.12.0) - '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.208.0 - '@opentelemetry/auto-instrumentations-node': 0.67.2(@opentelemetry/api@1.9.0)(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13) - '@opentelemetry/context-async-hooks': 2.2.0(@opentelemetry/api@1.9.0) - '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-trace-otlp-grpc': 0.208.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-trace-otlp-http': 0.208.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-node': 0.208.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.38.0 - '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 - tslib: 2.8.1 - transitivePeerDependencies: - - '@fastify/websocket' - - '@logtape/logtape' - - '@nats-io/nats-core' - - crossws - - encoding - - ioredis - - pino - - supports-color - - uWebSockets.js - - winston - - ws - - '@graphql-hive/plugin-opentelemetry@1.3.0(encoding@0.1.13)(graphql@16.12.0)(pino@10.3.0)(ws@8.18.0)': - dependencies: - '@graphql-hive/core': 0.18.0(graphql@16.12.0)(pino@10.3.0) - '@graphql-hive/gateway-runtime': 2.5.0(graphql@16.12.0)(pino@10.3.0)(ws@8.18.0) - '@graphql-hive/logger': 1.0.9(pino@10.3.0) - '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.12.0) - '@graphql-mesh/transport-common': 1.0.12(graphql@16.12.0)(pino@10.3.0) - '@graphql-mesh/types': 0.104.16(graphql@16.12.0) - '@graphql-mesh/utils': 0.104.16(graphql@16.12.0) - '@graphql-tools/utils': 10.11.0(graphql@16.12.0) - '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.208.0 - '@opentelemetry/auto-instrumentations-node': 0.67.2(@opentelemetry/api@1.9.0)(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13) - '@opentelemetry/context-async-hooks': 2.2.0(@opentelemetry/api@1.9.0) - '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-trace-otlp-grpc': 0.208.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-trace-otlp-http': 0.208.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-node': 0.208.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.38.0 - '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 - tslib: 2.8.1 - transitivePeerDependencies: - - '@fastify/websocket' - - '@logtape/logtape' - - '@nats-io/nats-core' - - crossws - - encoding - - ioredis - - pino - - supports-color - - uWebSockets.js - - winston - - ws - '@graphql-hive/plugin-opentelemetry@1.3.0(encoding@0.1.13)(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0)(ws@8.18.0)': dependencies: '@graphql-hive/core': 0.18.0(graphql@16.9.0)(pino@10.3.0) @@ -22883,18 +22574,6 @@ snapshots: '@graphql-hive/signal@2.0.0': {} - '@graphql-hive/yoga@0.46.0(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0)(pino@10.3.0)': - dependencies: - '@graphql-hive/core': 0.18.0(graphql@16.12.0)(pino@10.3.0) - '@graphql-hive/logger': 1.0.9(pino@10.3.0) - '@graphql-yoga/plugin-persisted-operations': 3.16.2(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0) - graphql: 16.12.0 - graphql-yoga: 5.17.1(graphql@16.12.0) - transitivePeerDependencies: - - '@logtape/logtape' - - pino - - winston - '@graphql-hive/yoga@0.46.0(graphql-yoga@5.17.1(graphql@16.9.0))(graphql@16.9.0)(pino@10.3.0)': dependencies: '@graphql-hive/core': 0.18.0(graphql@16.9.0)(pino@10.3.0) @@ -23138,48 +22817,48 @@ snapshots: - '@graphql-inspector/loaders' - yargs - '@graphql-mesh/cache-cfw-kv@0.105.16(graphql@16.12.0)(ioredis@5.8.2)': + '@graphql-mesh/cache-cfw-kv@0.105.16(graphql@16.9.0)(ioredis@5.8.2)': dependencies: - '@graphql-mesh/types': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) - '@graphql-mesh/utils': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-mesh/types': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) + '@graphql-mesh/utils': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 + graphql: 16.9.0 tslib: 2.8.1 transitivePeerDependencies: - '@nats-io/nats-core' - ioredis - '@graphql-mesh/cache-inmemory-lru@0.8.17(graphql@16.12.0)(ioredis@5.8.2)': + '@graphql-mesh/cache-inmemory-lru@0.8.17(graphql@16.9.0)(ioredis@5.8.2)': dependencies: - '@graphql-mesh/types': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) - '@graphql-mesh/utils': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-mesh/types': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) + '@graphql-mesh/utils': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) '@whatwg-node/disposablestack': 0.0.6 - graphql: 16.12.0 + graphql: 16.9.0 tslib: 2.8.1 transitivePeerDependencies: - '@nats-io/nats-core' - ioredis - '@graphql-mesh/cache-localforage@0.105.17(graphql@16.12.0)(ioredis@5.8.2)': + '@graphql-mesh/cache-localforage@0.105.17(graphql@16.9.0)(ioredis@5.8.2)': dependencies: - '@graphql-mesh/cache-inmemory-lru': 0.8.17(graphql@16.12.0)(ioredis@5.8.2) - '@graphql-mesh/types': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) - '@graphql-mesh/utils': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) - graphql: 16.12.0 + '@graphql-mesh/cache-inmemory-lru': 0.8.17(graphql@16.9.0)(ioredis@5.8.2) + '@graphql-mesh/types': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) + '@graphql-mesh/utils': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) + graphql: 16.9.0 localforage: 1.10.0 tslib: 2.8.1 transitivePeerDependencies: - '@nats-io/nats-core' - ioredis - '@graphql-mesh/cache-redis@0.105.2(@types/ioredis-mock@8.2.5)(graphql@16.12.0)': + '@graphql-mesh/cache-redis@0.105.2(@types/ioredis-mock@8.2.5)(graphql@16.9.0)': dependencies: - '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.12.0) - '@graphql-mesh/string-interpolation': 0.5.9(graphql@16.12.0) - '@graphql-mesh/types': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.9.0) + '@graphql-mesh/string-interpolation': 0.5.9(graphql@16.9.0) + '@graphql-mesh/types': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) '@opentelemetry/api': 1.9.0 '@whatwg-node/disposablestack': 0.0.6 - graphql: 16.12.0 + graphql: 16.9.0 ioredis: 5.8.2 ioredis-mock: 8.13.1(@types/ioredis-mock@8.2.5)(ioredis@5.8.2) tslib: 2.8.1 @@ -23188,114 +22867,46 @@ snapshots: - '@types/ioredis-mock' - supports-color - '@graphql-mesh/cache-upstash-redis@0.1.16(graphql@16.12.0)(ioredis@5.8.2)': + '@graphql-mesh/cache-upstash-redis@0.1.16(graphql@16.9.0)(ioredis@5.8.2)': dependencies: - '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.12.0) - '@graphql-mesh/types': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.9.0) + '@graphql-mesh/types': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) '@upstash/redis': 1.35.6 '@whatwg-node/disposablestack': 0.0.6 - graphql: 16.12.0 + graphql: 16.9.0 tslib: 2.8.1 transitivePeerDependencies: - '@nats-io/nats-core' - ioredis - '@graphql-mesh/cross-helpers@0.4.10(graphql@16.12.0)': - dependencies: - '@graphql-tools/utils': 10.9.1(graphql@16.12.0) - graphql: 16.12.0 - path-browserify: 1.0.1 - '@graphql-mesh/cross-helpers@0.4.10(graphql@16.9.0)': dependencies: '@graphql-tools/utils': 10.9.1(graphql@16.9.0) graphql: 16.9.0 path-browserify: 1.0.1 - '@graphql-mesh/fusion-runtime@1.6.2(@types/node@24.12.2)(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0)': - dependencies: - '@envelop/core': 5.5.1 - '@envelop/instrumentation': 1.0.0 - '@graphql-hive/logger': 1.0.9(pino@10.3.0) - '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.12.0) - '@graphql-mesh/transport-common': 1.0.12(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0) - '@graphql-mesh/types': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) - '@graphql-mesh/utils': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) - '@graphql-tools/batch-execute': 10.0.4(graphql@16.12.0) - '@graphql-tools/delegate': 12.0.2(graphql@16.12.0) - '@graphql-tools/executor': 1.5.0(graphql@16.12.0) - '@graphql-tools/federation': 4.2.6(@types/node@24.12.2)(graphql@16.12.0) - '@graphql-tools/merge': 9.1.5(graphql@16.12.0) - '@graphql-tools/stitch': 10.1.6(graphql@16.12.0) - '@graphql-tools/stitching-directives': 4.0.8(graphql@16.12.0) - '@graphql-tools/utils': 10.11.0(graphql@16.12.0) - '@graphql-tools/wrap': 11.1.2(graphql@16.12.0) - '@whatwg-node/disposablestack': 0.0.6 - '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 - graphql-yoga: 5.17.1(graphql@16.12.0) - tslib: 2.8.1 - transitivePeerDependencies: - - '@logtape/logtape' - - '@nats-io/nats-core' - - '@types/node' - - ioredis - - pino - - winston - - '@graphql-mesh/fusion-runtime@1.6.2(@types/node@25.5.0)(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0)': - dependencies: - '@envelop/core': 5.5.1 - '@envelop/instrumentation': 1.0.0 - '@graphql-hive/logger': 1.0.9(pino@10.3.0) - '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.12.0) - '@graphql-mesh/transport-common': 1.0.12(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0) - '@graphql-mesh/types': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) - '@graphql-mesh/utils': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) - '@graphql-tools/batch-execute': 10.0.4(graphql@16.12.0) - '@graphql-tools/delegate': 12.0.2(graphql@16.12.0) - '@graphql-tools/executor': 1.5.0(graphql@16.12.0) - '@graphql-tools/federation': 4.2.6(@types/node@25.5.0)(graphql@16.12.0) - '@graphql-tools/merge': 9.1.5(graphql@16.12.0) - '@graphql-tools/stitch': 10.1.6(graphql@16.12.0) - '@graphql-tools/stitching-directives': 4.0.8(graphql@16.12.0) - '@graphql-tools/utils': 10.11.0(graphql@16.12.0) - '@graphql-tools/wrap': 11.1.2(graphql@16.12.0) - '@whatwg-node/disposablestack': 0.0.6 - '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 - graphql-yoga: 5.17.1(graphql@16.12.0) - tslib: 2.8.1 - transitivePeerDependencies: - - '@logtape/logtape' - - '@nats-io/nats-core' - - '@types/node' - - ioredis - - pino - - winston - - '@graphql-mesh/fusion-runtime@1.6.2(@types/node@25.5.0)(graphql@16.12.0)(pino@10.3.0)': + '@graphql-mesh/fusion-runtime@1.6.2(@types/node@24.12.2)(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0)': dependencies: '@envelop/core': 5.5.1 '@envelop/instrumentation': 1.0.0 '@graphql-hive/logger': 1.0.9(pino@10.3.0) - '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.12.0) - '@graphql-mesh/transport-common': 1.0.12(graphql@16.12.0)(pino@10.3.0) - '@graphql-mesh/types': 0.104.16(graphql@16.12.0) - '@graphql-mesh/utils': 0.104.16(graphql@16.12.0) - '@graphql-tools/batch-execute': 10.0.4(graphql@16.12.0) - '@graphql-tools/delegate': 12.0.2(graphql@16.12.0) - '@graphql-tools/executor': 1.5.0(graphql@16.12.0) - '@graphql-tools/federation': 4.2.6(@types/node@25.5.0)(graphql@16.12.0) - '@graphql-tools/merge': 9.1.5(graphql@16.12.0) - '@graphql-tools/stitch': 10.1.6(graphql@16.12.0) - '@graphql-tools/stitching-directives': 4.0.8(graphql@16.12.0) - '@graphql-tools/utils': 10.11.0(graphql@16.12.0) - '@graphql-tools/wrap': 11.1.2(graphql@16.12.0) + '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.9.0) + '@graphql-mesh/transport-common': 1.0.12(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0) + '@graphql-mesh/types': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) + '@graphql-mesh/utils': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) + '@graphql-tools/batch-execute': 10.0.4(graphql@16.9.0) + '@graphql-tools/delegate': 12.0.2(graphql@16.9.0) + '@graphql-tools/executor': 1.5.0(graphql@16.9.0) + '@graphql-tools/federation': 4.2.6(@types/node@24.12.2)(graphql@16.9.0) + '@graphql-tools/merge': 9.1.5(graphql@16.9.0) + '@graphql-tools/stitch': 10.1.6(graphql@16.9.0) + '@graphql-tools/stitching-directives': 4.0.8(graphql@16.9.0) + '@graphql-tools/utils': 10.11.0(graphql@16.9.0) + '@graphql-tools/wrap': 11.1.2(graphql@16.9.0) '@whatwg-node/disposablestack': 0.0.6 '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 - graphql-yoga: 5.17.1(graphql@16.12.0) + graphql: 16.9.0 + graphql-yoga: 5.17.1(graphql@16.9.0) tslib: 2.8.1 transitivePeerDependencies: - '@logtape/logtape' @@ -23336,36 +22947,6 @@ snapshots: - pino - winston - '@graphql-mesh/hmac-upstream-signature@2.0.8(graphql@16.12.0)': - dependencies: - '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.12.0) - '@graphql-mesh/types': 0.104.16(graphql@16.12.0) - '@graphql-mesh/utils': 0.104.16(graphql@16.12.0) - '@graphql-tools/executor-common': 1.0.5(graphql@16.12.0) - '@graphql-tools/utils': 10.11.0(graphql@16.12.0) - '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 - json-stable-stringify: 1.3.0 - tslib: 2.8.1 - transitivePeerDependencies: - - '@nats-io/nats-core' - - ioredis - - '@graphql-mesh/hmac-upstream-signature@2.0.8(graphql@16.12.0)(ioredis@5.8.2)': - dependencies: - '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.12.0) - '@graphql-mesh/types': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) - '@graphql-mesh/utils': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) - '@graphql-tools/executor-common': 1.0.5(graphql@16.12.0) - '@graphql-tools/utils': 10.11.0(graphql@16.12.0) - '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 - json-stable-stringify: 1.3.0 - tslib: 2.8.1 - transitivePeerDependencies: - - '@nats-io/nats-core' - - ioredis - '@graphql-mesh/hmac-upstream-signature@2.0.8(graphql@16.9.0)(ioredis@5.8.2)': dependencies: '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.9.0) @@ -23381,37 +22962,37 @@ snapshots: - '@nats-io/nats-core' - ioredis - '@graphql-mesh/plugin-http-cache@0.105.17(graphql@16.12.0)(ioredis@5.8.2)': + '@graphql-mesh/plugin-http-cache@0.105.17(graphql@16.9.0)(ioredis@5.8.2)': dependencies: - '@graphql-mesh/types': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) - '@graphql-mesh/utils': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-mesh/types': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) + '@graphql-mesh/utils': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) '@whatwg-node/fetch': 0.10.13 '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 + graphql: 16.9.0 http-cache-semantics: 4.1.1 tslib: 2.8.1 transitivePeerDependencies: - '@nats-io/nats-core' - ioredis - '@graphql-mesh/plugin-jit@0.2.16(graphql@16.12.0)(ioredis@5.8.2)': + '@graphql-mesh/plugin-jit@0.2.16(graphql@16.9.0)(ioredis@5.8.2)': dependencies: '@envelop/core': 5.5.1 - '@graphql-mesh/utils': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) - '@graphql-tools/utils': 10.9.1(graphql@16.12.0) - graphql: 16.12.0 - graphql-jit: 0.8.7(graphql@16.12.0) + '@graphql-mesh/utils': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) + '@graphql-tools/utils': 10.9.1(graphql@16.9.0) + graphql: 16.9.0 + graphql-jit: 0.8.7(graphql@16.9.0) tslib: 2.8.1 transitivePeerDependencies: - '@nats-io/nats-core' - ioredis - '@graphql-mesh/plugin-jwt-auth@2.0.9(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0)(ioredis@5.8.2)': + '@graphql-mesh/plugin-jwt-auth@2.0.9(graphql-yoga@5.17.1(graphql@16.9.0))(graphql@16.9.0)(ioredis@5.8.2)': dependencies: - '@graphql-mesh/types': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) - '@graphql-mesh/utils': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) - '@graphql-yoga/plugin-jwt': 3.10.2(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0) - graphql: 16.12.0 + '@graphql-mesh/types': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) + '@graphql-mesh/utils': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) + '@graphql-yoga/plugin-jwt': 3.10.2(graphql-yoga@5.17.1(graphql@16.9.0))(graphql@16.9.0) + graphql: 16.9.0 tslib: 2.8.1 transitivePeerDependencies: - '@nats-io/nats-core' @@ -23419,16 +23000,16 @@ snapshots: - ioredis - supports-color - '@graphql-mesh/plugin-prometheus@2.1.5(@envelop/core@5.5.1)(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0)(prom-client@15.1.3)(ws@8.18.0)': + '@graphql-mesh/plugin-prometheus@2.1.5(@envelop/core@5.5.1)(graphql-yoga@5.17.1(graphql@16.9.0))(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0)(prom-client@15.1.3)(ws@8.18.0)': dependencies: - '@graphql-hive/gateway-runtime': 2.5.0(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0)(ws@8.18.0) + '@graphql-hive/gateway-runtime': 2.5.0(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0)(ws@8.18.0) '@graphql-hive/logger': 1.0.9(pino@10.3.0) - '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.12.0) - '@graphql-mesh/types': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) - '@graphql-mesh/utils': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) - '@graphql-tools/utils': 10.11.0(graphql@16.12.0) - '@graphql-yoga/plugin-prometheus': 6.11.3(@envelop/core@5.5.1)(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0)(prom-client@15.1.3) - graphql: 16.12.0 + '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.9.0) + '@graphql-mesh/types': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) + '@graphql-mesh/utils': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) + '@graphql-tools/utils': 10.11.0(graphql@16.9.0) + '@graphql-yoga/plugin-prometheus': 6.11.3(@envelop/core@5.5.1)(graphql-yoga@5.17.1(graphql@16.9.0))(graphql@16.9.0)(prom-client@15.1.3) + graphql: 16.9.0 prom-client: 15.1.3 tslib: 2.8.1 transitivePeerDependencies: @@ -23444,60 +23025,22 @@ snapshots: - winston - ws - '@graphql-mesh/plugin-rate-limit@0.105.5(@envelop/core@5.5.1)(graphql@16.12.0)(ioredis@5.8.2)': + '@graphql-mesh/plugin-rate-limit@0.105.5(@envelop/core@5.5.1)(graphql@16.9.0)(ioredis@5.8.2)': dependencies: - '@envelop/rate-limiter': 9.0.0(@envelop/core@5.5.1)(graphql@16.12.0) - '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.12.0) - '@graphql-mesh/string-interpolation': 0.5.9(graphql@16.12.0) - '@graphql-mesh/types': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) - '@graphql-mesh/utils': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) - '@graphql-tools/utils': 10.9.1(graphql@16.12.0) + '@envelop/rate-limiter': 9.0.0(@envelop/core@5.5.1)(graphql@16.9.0) + '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.9.0) + '@graphql-mesh/string-interpolation': 0.5.9(graphql@16.9.0) + '@graphql-mesh/types': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) + '@graphql-mesh/utils': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) + '@graphql-tools/utils': 10.9.1(graphql@16.9.0) '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 + graphql: 16.9.0 tslib: 2.8.1 transitivePeerDependencies: - '@envelop/core' - '@nats-io/nats-core' - ioredis - '@graphql-mesh/plugin-response-cache@0.104.18(graphql@16.12.0)': - dependencies: - '@envelop/core': 5.5.1 - '@envelop/response-cache': 9.0.0(@envelop/core@5.5.1)(graphql@16.12.0) - '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.12.0) - '@graphql-mesh/string-interpolation': 0.5.9(graphql@16.12.0) - '@graphql-mesh/types': 0.104.16(graphql@16.12.0) - '@graphql-mesh/utils': 0.104.16(graphql@16.12.0) - '@graphql-tools/utils': 10.9.1(graphql@16.12.0) - '@graphql-yoga/plugin-response-cache': 3.15.4(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0) - '@whatwg-node/promise-helpers': 1.3.2 - cache-control-parser: 2.0.6 - graphql: 16.12.0 - graphql-yoga: 5.17.1(graphql@16.12.0) - tslib: 2.8.1 - transitivePeerDependencies: - - '@nats-io/nats-core' - - ioredis - - '@graphql-mesh/plugin-response-cache@0.104.18(graphql@16.12.0)(ioredis@5.8.2)': - dependencies: - '@envelop/core': 5.5.1 - '@envelop/response-cache': 9.0.0(@envelop/core@5.5.1)(graphql@16.12.0) - '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.12.0) - '@graphql-mesh/string-interpolation': 0.5.9(graphql@16.12.0) - '@graphql-mesh/types': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) - '@graphql-mesh/utils': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) - '@graphql-tools/utils': 10.9.1(graphql@16.12.0) - '@graphql-yoga/plugin-response-cache': 3.15.4(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0) - '@whatwg-node/promise-helpers': 1.3.2 - cache-control-parser: 2.0.6 - graphql: 16.12.0 - graphql-yoga: 5.17.1(graphql@16.12.0) - tslib: 2.8.1 - transitivePeerDependencies: - - '@nats-io/nats-core' - - ioredis - '@graphql-mesh/plugin-response-cache@0.104.18(graphql@16.9.0)(ioredis@5.8.2)': dependencies: '@envelop/core': 5.5.1 @@ -23517,28 +23060,20 @@ snapshots: - '@nats-io/nats-core' - ioredis - '@graphql-mesh/plugin-snapshot@0.104.16(graphql@16.12.0)(ioredis@5.8.2)': + '@graphql-mesh/plugin-snapshot@0.104.16(graphql@16.9.0)(ioredis@5.8.2)': dependencies: - '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.12.0) - '@graphql-mesh/string-interpolation': 0.5.9(graphql@16.12.0) - '@graphql-mesh/types': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) - '@graphql-mesh/utils': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.9.0) + '@graphql-mesh/string-interpolation': 0.5.9(graphql@16.9.0) + '@graphql-mesh/types': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) + '@graphql-mesh/utils': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) '@whatwg-node/fetch': 0.10.13 - graphql: 16.12.0 + graphql: 16.9.0 minimatch: 10.2.4 tslib: 2.8.1 transitivePeerDependencies: - '@nats-io/nats-core' - ioredis - '@graphql-mesh/string-interpolation@0.5.9(graphql@16.12.0)': - dependencies: - dayjs: 1.11.18 - graphql: 16.12.0 - json-pointer: 0.6.2 - lodash.get: 4.4.2 - tslib: 2.8.1 - '@graphql-mesh/string-interpolation@0.5.9(graphql@16.9.0)': dependencies: dayjs: 1.11.18 @@ -23547,44 +23082,6 @@ snapshots: lodash.get: 4.4.2 tslib: 2.8.1 - '@graphql-mesh/transport-common@1.0.12(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0)': - dependencies: - '@envelop/core': 5.5.1 - '@graphql-hive/logger': 1.0.9(pino@10.3.0) - '@graphql-hive/pubsub': 2.1.1(ioredis@5.8.2) - '@graphql-hive/signal': 2.0.0 - '@graphql-mesh/types': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) - '@graphql-tools/executor': 1.5.0(graphql@16.12.0) - '@graphql-tools/executor-common': 1.0.5(graphql@16.12.0) - '@graphql-tools/utils': 10.11.0(graphql@16.12.0) - graphql: 16.12.0 - tslib: 2.8.1 - transitivePeerDependencies: - - '@logtape/logtape' - - '@nats-io/nats-core' - - ioredis - - pino - - winston - - '@graphql-mesh/transport-common@1.0.12(graphql@16.12.0)(pino@10.3.0)': - dependencies: - '@envelop/core': 5.5.1 - '@graphql-hive/logger': 1.0.9(pino@10.3.0) - '@graphql-hive/pubsub': 2.1.1(ioredis@5.8.2) - '@graphql-hive/signal': 2.0.0 - '@graphql-mesh/types': 0.104.16(graphql@16.12.0) - '@graphql-tools/executor': 1.5.0(graphql@16.12.0) - '@graphql-tools/executor-common': 1.0.5(graphql@16.12.0) - '@graphql-tools/utils': 10.11.0(graphql@16.12.0) - graphql: 16.12.0 - tslib: 2.8.1 - transitivePeerDependencies: - - '@logtape/logtape' - - '@nats-io/nats-core' - - ioredis - - pino - - winston - '@graphql-mesh/transport-common@1.0.12(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0)': dependencies: '@envelop/core': 5.5.1 @@ -23604,20 +23101,20 @@ snapshots: - pino - winston - '@graphql-mesh/transport-http-callback@1.0.12(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0)': + '@graphql-mesh/transport-http-callback@1.0.12(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0)': dependencies: '@graphql-hive/signal': 2.0.0 - '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.12.0) - '@graphql-mesh/string-interpolation': 0.5.9(graphql@16.12.0) - '@graphql-mesh/transport-common': 1.0.12(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0) - '@graphql-mesh/types': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) - '@graphql-mesh/utils': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) - '@graphql-tools/executor-common': 1.0.5(graphql@16.12.0) - '@graphql-tools/utils': 10.11.0(graphql@16.12.0) + '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.9.0) + '@graphql-mesh/string-interpolation': 0.5.9(graphql@16.9.0) + '@graphql-mesh/transport-common': 1.0.12(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0) + '@graphql-mesh/types': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) + '@graphql-mesh/utils': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) + '@graphql-tools/executor-common': 1.0.5(graphql@16.9.0) + '@graphql-tools/utils': 10.11.0(graphql@16.9.0) '@repeaterjs/repeater': 3.0.6 '@whatwg-node/fetch': 0.10.13 '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 + graphql: 16.9.0 tslib: 2.8.1 transitivePeerDependencies: - '@logtape/logtape' @@ -23626,17 +23123,17 @@ snapshots: - pino - winston - '@graphql-mesh/transport-http@1.0.12(@types/node@24.12.2)(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0)': + '@graphql-mesh/transport-http@1.0.12(@types/node@24.12.2)(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0)': dependencies: - '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.12.0) - '@graphql-mesh/string-interpolation': 0.5.9(graphql@16.12.0) - '@graphql-mesh/transport-common': 1.0.12(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0) - '@graphql-mesh/types': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) - '@graphql-mesh/utils': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) - '@graphql-tools/executor-http': 3.0.7(@types/node@24.12.2)(graphql@16.12.0) - '@graphql-tools/utils': 10.11.0(graphql@16.12.0) + '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.9.0) + '@graphql-mesh/string-interpolation': 0.5.9(graphql@16.9.0) + '@graphql-mesh/transport-common': 1.0.12(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0) + '@graphql-mesh/types': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) + '@graphql-mesh/utils': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) + '@graphql-tools/executor-http': 3.0.7(@types/node@24.12.2)(graphql@16.9.0) + '@graphql-tools/utils': 10.11.0(graphql@16.9.0) '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 + graphql: 16.9.0 tslib: 2.8.1 transitivePeerDependencies: - '@logtape/logtape' @@ -23646,17 +23143,17 @@ snapshots: - pino - winston - '@graphql-mesh/transport-ws@2.0.12(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0)': + '@graphql-mesh/transport-ws@2.0.12(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0)': dependencies: - '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.12.0) - '@graphql-mesh/string-interpolation': 0.5.9(graphql@16.12.0) - '@graphql-mesh/transport-common': 1.0.12(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0) - '@graphql-mesh/types': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) - '@graphql-mesh/utils': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) - '@graphql-tools/executor-graphql-ws': 3.1.3(graphql@16.12.0) - '@graphql-tools/utils': 10.11.0(graphql@16.12.0) - graphql: 16.12.0 - graphql-ws: 6.0.6(graphql@16.12.0)(ws@8.18.0) + '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.9.0) + '@graphql-mesh/string-interpolation': 0.5.9(graphql@16.9.0) + '@graphql-mesh/transport-common': 1.0.12(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0) + '@graphql-mesh/types': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) + '@graphql-mesh/utils': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) + '@graphql-tools/executor-graphql-ws': 3.1.3(graphql@16.9.0) + '@graphql-tools/utils': 10.11.0(graphql@16.9.0) + graphql: 16.9.0 + graphql-ws: 6.0.6(graphql@16.9.0)(ws@8.18.0) tslib: 2.8.1 ws: 8.18.0 transitivePeerDependencies: @@ -23671,36 +23168,6 @@ snapshots: - utf-8-validate - winston - '@graphql-mesh/types@0.104.16(graphql@16.12.0)': - dependencies: - '@graphql-hive/pubsub': 2.1.1(ioredis@5.8.2) - '@graphql-tools/batch-delegate': 10.0.5(graphql@16.12.0) - '@graphql-tools/delegate': 11.1.3(graphql@16.12.0) - '@graphql-tools/utils': 10.9.1(graphql@16.12.0) - '@graphql-typed-document-node/core': 3.2.0(graphql@16.12.0) - '@repeaterjs/repeater': 3.0.6 - '@whatwg-node/disposablestack': 0.0.6 - graphql: 16.12.0 - tslib: 2.8.1 - transitivePeerDependencies: - - '@nats-io/nats-core' - - ioredis - - '@graphql-mesh/types@0.104.16(graphql@16.12.0)(ioredis@5.8.2)': - dependencies: - '@graphql-hive/pubsub': 2.1.1(ioredis@5.8.2) - '@graphql-tools/batch-delegate': 10.0.5(graphql@16.12.0) - '@graphql-tools/delegate': 11.1.3(graphql@16.12.0) - '@graphql-tools/utils': 10.9.1(graphql@16.12.0) - '@graphql-typed-document-node/core': 3.2.0(graphql@16.12.0) - '@repeaterjs/repeater': 3.0.6 - '@whatwg-node/disposablestack': 0.0.6 - graphql: 16.12.0 - tslib: 2.8.1 - transitivePeerDependencies: - - '@nats-io/nats-core' - - ioredis - '@graphql-mesh/types@0.104.16(graphql@16.9.0)(ioredis@5.8.2)': dependencies: '@graphql-hive/pubsub': 2.1.1(ioredis@5.8.2) @@ -23716,54 +23183,6 @@ snapshots: - '@nats-io/nats-core' - ioredis - '@graphql-mesh/utils@0.104.16(graphql@16.12.0)': - dependencies: - '@envelop/instrumentation': 1.0.0 - '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.12.0) - '@graphql-mesh/string-interpolation': 0.5.9(graphql@16.12.0) - '@graphql-mesh/types': 0.104.16(graphql@16.12.0) - '@graphql-tools/batch-delegate': 10.0.5(graphql@16.12.0) - '@graphql-tools/delegate': 11.1.3(graphql@16.12.0) - '@graphql-tools/utils': 10.9.1(graphql@16.12.0) - '@graphql-tools/wrap': 11.1.2(graphql@16.12.0) - '@whatwg-node/disposablestack': 0.0.6 - '@whatwg-node/fetch': 0.10.13 - '@whatwg-node/promise-helpers': 1.3.2 - dset: 3.1.4 - graphql: 16.12.0 - js-yaml: 4.1.1 - lodash.get: 4.4.2 - lodash.topath: 4.5.2 - tiny-lru: 11.4.7 - tslib: 2.8.1 - transitivePeerDependencies: - - '@nats-io/nats-core' - - ioredis - - '@graphql-mesh/utils@0.104.16(graphql@16.12.0)(ioredis@5.8.2)': - dependencies: - '@envelop/instrumentation': 1.0.0 - '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.12.0) - '@graphql-mesh/string-interpolation': 0.5.9(graphql@16.12.0) - '@graphql-mesh/types': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) - '@graphql-tools/batch-delegate': 10.0.5(graphql@16.12.0) - '@graphql-tools/delegate': 11.1.3(graphql@16.12.0) - '@graphql-tools/utils': 10.9.1(graphql@16.12.0) - '@graphql-tools/wrap': 11.1.2(graphql@16.12.0) - '@whatwg-node/disposablestack': 0.0.6 - '@whatwg-node/fetch': 0.10.13 - '@whatwg-node/promise-helpers': 1.3.2 - dset: 3.1.4 - graphql: 16.12.0 - js-yaml: 4.1.1 - lodash.get: 4.4.2 - lodash.topath: 4.5.2 - tiny-lru: 11.4.7 - tslib: 2.8.1 - transitivePeerDependencies: - - '@nats-io/nats-core' - - ioredis - '@graphql-mesh/utils@0.104.16(graphql@16.9.0)(ioredis@5.8.2)': dependencies: '@envelop/instrumentation': 1.0.0 @@ -23796,15 +23215,6 @@ snapshots: sync-fetch: 0.6.0-2 tslib: 2.8.1 - '@graphql-tools/batch-delegate@10.0.5(graphql@16.12.0)': - dependencies: - '@graphql-tools/delegate': 11.1.3(graphql@16.12.0) - '@graphql-tools/utils': 10.11.0(graphql@16.12.0) - '@whatwg-node/promise-helpers': 1.3.2 - dataloader: 2.2.3 - graphql: 16.12.0 - tslib: 2.8.1 - '@graphql-tools/batch-delegate@10.0.5(graphql@16.9.0)': dependencies: '@graphql-tools/delegate': 11.1.3(graphql@16.9.0) @@ -23814,15 +23224,6 @@ snapshots: graphql: 16.9.0 tslib: 2.8.1 - '@graphql-tools/batch-delegate@10.0.8(graphql@16.12.0)': - dependencies: - '@graphql-tools/delegate': 12.0.2(graphql@16.12.0) - '@graphql-tools/utils': 10.11.0(graphql@16.12.0) - '@whatwg-node/promise-helpers': 1.3.2 - dataloader: 2.2.3 - graphql: 16.12.0 - tslib: 2.8.1 - '@graphql-tools/batch-delegate@10.0.8(graphql@16.9.0)': dependencies: '@graphql-tools/delegate': 12.0.2(graphql@16.9.0) @@ -23926,12 +23327,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@graphql-tools/code-file-loader@8.1.26(graphql@16.12.0)': + '@graphql-tools/code-file-loader@8.1.26(graphql@16.9.0)': dependencies: - '@graphql-tools/graphql-tag-pluck': 8.3.25(graphql@16.12.0) - '@graphql-tools/utils': 10.11.0(graphql@16.12.0) + '@graphql-tools/graphql-tag-pluck': 8.3.25(graphql@16.9.0) + '@graphql-tools/utils': 10.11.0(graphql@16.9.0) globby: 11.1.0 - graphql: 16.12.0 + graphql: 16.9.0 tslib: 2.8.1 unixify: 1.0.0 transitivePeerDependencies: @@ -23962,18 +23363,6 @@ snapshots: graphql: 16.9.0 tslib: 2.8.1 - '@graphql-tools/delegate@11.1.3(graphql@16.12.0)': - dependencies: - '@graphql-tools/batch-execute': 10.0.4(graphql@16.12.0) - '@graphql-tools/executor': 1.5.0(graphql@16.12.0) - '@graphql-tools/schema': 10.0.29(graphql@16.12.0) - '@graphql-tools/utils': 10.11.0(graphql@16.12.0) - '@repeaterjs/repeater': 3.0.6 - '@whatwg-node/promise-helpers': 1.3.2 - dataloader: 2.2.3 - graphql: 16.12.0 - tslib: 2.8.1 - '@graphql-tools/delegate@11.1.3(graphql@16.9.0)': dependencies: '@graphql-tools/batch-execute': 10.0.4(graphql@16.9.0) @@ -24045,12 +23434,6 @@ snapshots: '@graphql-tools/utils': 10.9.1(graphql@16.9.0) graphql: 16.9.0 - '@graphql-tools/executor-common@1.0.5(graphql@16.12.0)': - dependencies: - '@envelop/core': 5.5.1 - '@graphql-tools/utils': 10.11.0(graphql@16.12.0) - graphql: 16.12.0 - '@graphql-tools/executor-common@1.0.5(graphql@16.9.0)': dependencies: '@envelop/core': 5.5.1 @@ -24122,13 +23505,13 @@ snapshots: - uWebSockets.js - utf-8-validate - '@graphql-tools/executor-graphql-ws@3.1.3(graphql@16.12.0)': + '@graphql-tools/executor-graphql-ws@3.1.3(graphql@16.9.0)': dependencies: - '@graphql-tools/executor-common': 1.0.5(graphql@16.12.0) - '@graphql-tools/utils': 10.11.0(graphql@16.12.0) + '@graphql-tools/executor-common': 1.0.5(graphql@16.9.0) + '@graphql-tools/utils': 10.11.0(graphql@16.9.0) '@whatwg-node/disposablestack': 0.0.6 - graphql: 16.12.0 - graphql-ws: 6.0.6(graphql@16.12.0)(ws@8.18.0) + graphql: 16.9.0 + graphql-ws: 6.0.6(graphql@16.9.0)(ws@8.18.0) isows: 1.0.7(ws@8.18.0) tslib: 2.8.1 ws: 8.18.0 @@ -24213,36 +23596,21 @@ snapshots: transitivePeerDependencies: - '@types/node' - '@graphql-tools/executor-http@3.0.7(@types/node@24.12.2)(graphql@16.12.0)': + '@graphql-tools/executor-http@3.0.7(@types/node@24.12.2)(graphql@16.9.0)': dependencies: '@graphql-hive/signal': 2.0.0 - '@graphql-tools/executor-common': 1.0.5(graphql@16.12.0) - '@graphql-tools/utils': 10.11.0(graphql@16.12.0) + '@graphql-tools/executor-common': 1.0.5(graphql@16.9.0) + '@graphql-tools/utils': 10.11.0(graphql@16.9.0) '@repeaterjs/repeater': 3.0.6 '@whatwg-node/disposablestack': 0.0.6 '@whatwg-node/fetch': 0.10.13 '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 + graphql: 16.9.0 meros: 1.3.2(@types/node@24.12.2) tslib: 2.8.1 transitivePeerDependencies: - '@types/node' - '@graphql-tools/executor-http@3.0.7(@types/node@25.5.0)(graphql@16.12.0)': - dependencies: - '@graphql-hive/signal': 2.0.0 - '@graphql-tools/executor-common': 1.0.5(graphql@16.12.0) - '@graphql-tools/utils': 10.11.0(graphql@16.12.0) - '@repeaterjs/repeater': 3.0.6 - '@whatwg-node/disposablestack': 0.0.6 - '@whatwg-node/fetch': 0.10.13 - '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 - meros: 1.3.2(@types/node@25.5.0) - tslib: 2.8.1 - transitivePeerDependencies: - - '@types/node' - '@graphql-tools/executor-http@3.0.7(@types/node@25.5.0)(graphql@16.9.0)': dependencies: '@graphql-hive/signal': 2.0.0 @@ -24360,42 +23728,22 @@ snapshots: graphql: 16.9.0 tslib: 2.8.1 - '@graphql-tools/federation@4.2.6(@types/node@24.12.2)(graphql@16.12.0)': + '@graphql-tools/federation@4.2.6(@types/node@24.12.2)(graphql@16.9.0)': dependencies: - '@graphql-tools/delegate': 12.0.2(graphql@16.12.0) - '@graphql-tools/executor': 1.5.0(graphql@16.12.0) - '@graphql-tools/executor-http': 3.0.7(@types/node@24.12.2)(graphql@16.12.0) - '@graphql-tools/merge': 9.1.5(graphql@16.12.0) - '@graphql-tools/schema': 10.0.29(graphql@16.12.0) - '@graphql-tools/stitch': 10.1.6(graphql@16.12.0) - '@graphql-tools/utils': 10.11.0(graphql@16.12.0) - '@graphql-tools/wrap': 11.1.2(graphql@16.12.0) - '@graphql-yoga/typed-event-target': 3.0.2 - '@whatwg-node/disposablestack': 0.0.6 - '@whatwg-node/events': 0.1.2 - '@whatwg-node/fetch': 0.10.13 - '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 - tslib: 2.8.1 - transitivePeerDependencies: - - '@types/node' - - '@graphql-tools/federation@4.2.6(@types/node@25.5.0)(graphql@16.12.0)': - dependencies: - '@graphql-tools/delegate': 12.0.2(graphql@16.12.0) - '@graphql-tools/executor': 1.5.0(graphql@16.12.0) - '@graphql-tools/executor-http': 3.0.7(@types/node@25.5.0)(graphql@16.12.0) - '@graphql-tools/merge': 9.1.5(graphql@16.12.0) - '@graphql-tools/schema': 10.0.29(graphql@16.12.0) - '@graphql-tools/stitch': 10.1.6(graphql@16.12.0) - '@graphql-tools/utils': 10.11.0(graphql@16.12.0) - '@graphql-tools/wrap': 11.1.2(graphql@16.12.0) + '@graphql-tools/delegate': 12.0.2(graphql@16.9.0) + '@graphql-tools/executor': 1.5.0(graphql@16.9.0) + '@graphql-tools/executor-http': 3.0.7(@types/node@24.12.2)(graphql@16.9.0) + '@graphql-tools/merge': 9.1.5(graphql@16.9.0) + '@graphql-tools/schema': 10.0.29(graphql@16.9.0) + '@graphql-tools/stitch': 10.1.6(graphql@16.9.0) + '@graphql-tools/utils': 10.11.0(graphql@16.9.0) + '@graphql-tools/wrap': 11.1.2(graphql@16.9.0) '@graphql-yoga/typed-event-target': 3.0.2 '@whatwg-node/disposablestack': 0.0.6 '@whatwg-node/events': 0.1.2 '@whatwg-node/fetch': 0.10.13 '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 + graphql: 16.9.0 tslib: 2.8.1 transitivePeerDependencies: - '@types/node' @@ -24504,17 +23852,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@graphql-tools/graphql-file-loader@8.1.7(graphql@16.12.0)': - dependencies: - '@graphql-tools/import': 7.1.7(graphql@16.12.0) - '@graphql-tools/utils': 10.11.0(graphql@16.12.0) - globby: 11.1.0 - graphql: 16.12.0 - tslib: 2.8.1 - unixify: 1.0.0 - transitivePeerDependencies: - - supports-color - '@graphql-tools/graphql-file-loader@8.1.7(graphql@16.9.0)': dependencies: '@graphql-tools/import': 7.1.7(graphql@16.9.0) @@ -24591,19 +23928,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@graphql-tools/graphql-tag-pluck@8.3.25(graphql@16.12.0)': - dependencies: - '@babel/core': 7.28.5 - '@babel/parser': 7.29.0 - '@babel/plugin-syntax-import-assertions': 7.27.1(@babel/core@7.28.5) - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - '@graphql-tools/utils': 10.11.0(graphql@16.12.0) - graphql: 16.12.0 - tslib: 2.8.1 - transitivePeerDependencies: - - supports-color - '@graphql-tools/graphql-tag-pluck@8.3.25(graphql@16.9.0)': dependencies: '@babel/core': 7.28.5 @@ -24641,16 +23965,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@graphql-tools/import@7.1.7(graphql@16.12.0)': - dependencies: - '@graphql-tools/utils': 10.11.0(graphql@16.12.0) - '@theguild/federation-composition': 0.20.2(graphql@16.12.0) - graphql: 16.12.0 - resolve-from: 5.0.0 - tslib: 2.8.1 - transitivePeerDependencies: - - supports-color - '@graphql-tools/import@7.1.7(graphql@16.9.0)': dependencies: '@graphql-tools/utils': 10.11.0(graphql@16.9.0) @@ -24709,11 +24023,11 @@ snapshots: p-limit: 3.1.0 tslib: 2.8.1 - '@graphql-tools/load@8.1.6(graphql@16.12.0)': + '@graphql-tools/load@8.1.6(graphql@16.9.0)': dependencies: - '@graphql-tools/schema': 10.0.29(graphql@16.12.0) - '@graphql-tools/utils': 10.11.0(graphql@16.12.0) - graphql: 16.12.0 + '@graphql-tools/schema': 10.0.29(graphql@16.9.0) + '@graphql-tools/utils': 10.11.0(graphql@16.9.0) + graphql: 16.9.0 p-limit: 3.1.0 tslib: 2.8.1 @@ -24723,12 +24037,6 @@ snapshots: graphql: 16.9.0 tslib: 2.8.1 - '@graphql-tools/merge@9.1.1(graphql@16.12.0)': - dependencies: - '@graphql-tools/utils': 10.9.1(graphql@16.12.0) - graphql: 16.12.0 - tslib: 2.8.1 - '@graphql-tools/merge@9.1.1(graphql@16.9.0)': dependencies: '@graphql-tools/utils': 10.9.1(graphql@16.9.0) @@ -24767,13 +24075,6 @@ snapshots: graphql: 16.9.0 tslib: 2.8.1 - '@graphql-tools/schema@10.0.25(graphql@16.12.0)': - dependencies: - '@graphql-tools/merge': 9.1.1(graphql@16.12.0) - '@graphql-tools/utils': 10.9.1(graphql@16.12.0) - graphql: 16.12.0 - tslib: 2.8.1 - '@graphql-tools/schema@10.0.25(graphql@16.9.0)': dependencies: '@graphql-tools/merge': 9.1.1(graphql@16.9.0) @@ -24803,19 +24104,6 @@ snapshots: tslib: 2.8.1 value-or-promise: 1.0.12 - '@graphql-tools/stitch@10.1.6(graphql@16.12.0)': - dependencies: - '@graphql-tools/batch-delegate': 10.0.8(graphql@16.12.0) - '@graphql-tools/delegate': 12.0.2(graphql@16.12.0) - '@graphql-tools/executor': 1.5.0(graphql@16.12.0) - '@graphql-tools/merge': 9.1.5(graphql@16.12.0) - '@graphql-tools/schema': 10.0.29(graphql@16.12.0) - '@graphql-tools/utils': 10.11.0(graphql@16.12.0) - '@graphql-tools/wrap': 11.1.2(graphql@16.12.0) - '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 - tslib: 2.8.1 - '@graphql-tools/stitch@10.1.6(graphql@16.9.0)': dependencies: '@graphql-tools/batch-delegate': 10.0.8(graphql@16.9.0) @@ -24849,13 +24137,6 @@ snapshots: graphql: 16.9.0 tslib: 2.8.1 - '@graphql-tools/stitching-directives@4.0.8(graphql@16.12.0)': - dependencies: - '@graphql-tools/delegate': 12.0.2(graphql@16.12.0) - '@graphql-tools/utils': 10.11.0(graphql@16.12.0) - graphql: 16.12.0 - tslib: 2.8.1 - '@graphql-tools/stitching-directives@4.0.8(graphql@16.9.0)': dependencies: '@graphql-tools/delegate': 12.0.2(graphql@16.9.0) @@ -25030,15 +24311,6 @@ snapshots: graphql: 16.9.0 tslib: 2.8.1 - '@graphql-tools/utils@10.9.1(graphql@16.12.0)': - dependencies: - '@graphql-typed-document-node/core': 3.2.0(graphql@16.12.0) - '@whatwg-node/promise-helpers': 1.3.1 - cross-inspect: 1.0.1 - dset: 3.1.4 - graphql: 16.12.0 - tslib: 2.8.1 - '@graphql-tools/utils@10.9.1(graphql@16.9.0)': dependencies: '@graphql-typed-document-node/core': 3.2.0(graphql@16.9.0) @@ -25135,16 +24407,6 @@ snapshots: dependencies: tslib: 2.8.1 - '@graphql-yoga/plugin-apollo-inline-trace@3.17.1(@envelop/core@5.5.1)(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0)': - dependencies: - '@apollo/usage-reporting-protobuf': 4.1.1 - '@envelop/on-resolve': 7.0.0(@envelop/core@5.5.1)(graphql@16.12.0) - graphql: 16.12.0 - graphql-yoga: 5.17.1(graphql@16.12.0) - tslib: 2.8.1 - transitivePeerDependencies: - - '@envelop/core' - '@graphql-yoga/plugin-apollo-inline-trace@3.17.1(@envelop/core@5.5.1)(graphql-yoga@5.17.1(graphql@16.9.0))(graphql@16.9.0)': dependencies: '@apollo/usage-reporting-protobuf': 4.1.1 @@ -25155,20 +24417,6 @@ snapshots: transitivePeerDependencies: - '@envelop/core' - '@graphql-yoga/plugin-apollo-usage-report@0.12.1(@envelop/core@5.5.1)(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0)': - dependencies: - '@apollo/server-gateway-interface': 2.0.0(graphql@16.12.0) - '@apollo/usage-reporting-protobuf': 4.1.1 - '@apollo/utils.usagereporting': 2.1.0(graphql@16.12.0) - '@graphql-tools/utils': 10.11.0(graphql@16.12.0) - '@graphql-yoga/plugin-apollo-inline-trace': 3.17.1(@envelop/core@5.5.1)(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0) - '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 - graphql-yoga: 5.17.1(graphql@16.12.0) - tslib: 2.8.1 - transitivePeerDependencies: - - '@envelop/core' - '@graphql-yoga/plugin-apollo-usage-report@0.12.1(@envelop/core@5.5.1)(graphql-yoga@5.17.1(graphql@16.9.0))(graphql@16.9.0)': dependencies: '@apollo/server-gateway-interface': 2.0.0(graphql@16.9.0) @@ -25183,20 +24431,10 @@ snapshots: transitivePeerDependencies: - '@envelop/core' - '@graphql-yoga/plugin-csrf-prevention@3.16.2(graphql-yoga@5.17.1(graphql@16.12.0))': - dependencies: - graphql-yoga: 5.17.1(graphql@16.12.0) - '@graphql-yoga/plugin-csrf-prevention@3.16.2(graphql-yoga@5.17.1(graphql@16.9.0))': dependencies: graphql-yoga: 5.17.1(graphql@16.9.0) - '@graphql-yoga/plugin-defer-stream@3.16.2(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0)': - dependencies: - '@graphql-tools/utils': 10.9.1(graphql@16.12.0) - graphql: 16.12.0 - graphql-yoga: 5.17.1(graphql@16.12.0) - '@graphql-yoga/plugin-defer-stream@3.16.2(graphql-yoga@5.17.1(graphql@16.9.0))(graphql@16.9.0)': dependencies: '@graphql-tools/utils': 10.9.1(graphql@16.9.0) @@ -25220,24 +24458,18 @@ snapshots: graphql-sse: 2.6.0(graphql@16.9.0) graphql-yoga: 5.13.3(graphql@16.9.0) - '@graphql-yoga/plugin-jwt@3.10.2(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0)': + '@graphql-yoga/plugin-jwt@3.10.2(graphql-yoga@5.17.1(graphql@16.9.0))(graphql@16.9.0)': dependencies: '@whatwg-node/promise-helpers': 1.3.2 '@whatwg-node/server-plugin-cookies': 1.0.5 - graphql: 16.12.0 - graphql-yoga: 5.17.1(graphql@16.12.0) + graphql: 16.9.0 + graphql-yoga: 5.17.1(graphql@16.9.0) jsonwebtoken: 9.0.3 jwks-rsa: 3.2.0 tslib: 2.8.1 transitivePeerDependencies: - supports-color - '@graphql-yoga/plugin-persisted-operations@3.16.2(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0)': - dependencies: - '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.12.0 - graphql-yoga: 5.17.1(graphql@16.12.0) - '@graphql-yoga/plugin-persisted-operations@3.16.2(graphql-yoga@5.17.1(graphql@16.9.0))(graphql@16.9.0)': dependencies: '@whatwg-node/promise-helpers': 1.3.2 @@ -25250,11 +24482,11 @@ snapshots: graphql: 16.9.0 graphql-yoga: 5.13.3(graphql@16.9.0) - '@graphql-yoga/plugin-prometheus@6.11.3(@envelop/core@5.5.1)(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0)(prom-client@15.1.3)': + '@graphql-yoga/plugin-prometheus@6.11.3(@envelop/core@5.5.1)(graphql-yoga@5.17.1(graphql@16.9.0))(graphql@16.9.0)(prom-client@15.1.3)': dependencies: - '@envelop/prometheus': 14.0.0(@envelop/core@5.5.1)(graphql@16.12.0)(prom-client@15.1.3) - graphql: 16.12.0 - graphql-yoga: 5.17.1(graphql@16.12.0) + '@envelop/prometheus': 14.0.0(@envelop/core@5.5.1)(graphql@16.9.0)(prom-client@15.1.3) + graphql: 16.9.0 + graphql-yoga: 5.17.1(graphql@16.9.0) prom-client: 15.1.3 transitivePeerDependencies: - '@envelop/core' @@ -25267,14 +24499,6 @@ snapshots: graphql: 16.9.0 graphql-yoga: 5.13.3(graphql@16.9.0) - '@graphql-yoga/plugin-response-cache@3.15.4(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0)': - dependencies: - '@envelop/core': 5.5.1 - '@envelop/response-cache': 7.1.3(@envelop/core@5.5.1)(graphql@16.12.0) - '@whatwg-node/promise-helpers': 1.3.0 - graphql: 16.12.0 - graphql-yoga: 5.17.1(graphql@16.12.0) - '@graphql-yoga/plugin-response-cache@3.15.4(graphql-yoga@5.17.1(graphql@16.9.0))(graphql@16.9.0)': dependencies: '@envelop/core': 5.5.1 @@ -25297,9 +24521,9 @@ snapshots: '@whatwg-node/events': 0.1.2 ioredis: 5.8.2 - '@graphql-yoga/render-graphiql@5.16.2(graphql-yoga@5.17.1(graphql@16.12.0))': + '@graphql-yoga/render-graphiql@5.16.2(graphql-yoga@5.17.1(graphql@16.9.0))': dependencies: - graphql-yoga: 5.17.1(graphql@16.12.0) + graphql-yoga: 5.17.1(graphql@16.9.0) '@graphql-yoga/subscription@5.0.5': dependencies: @@ -29611,7 +28835,7 @@ snapshots: '@types/retry': 0.12.0 axios: 1.15.0 eventemitter3: 5.0.1 - form-data: 4.0.4 + form-data: 4.0.5 is-electron: 2.2.2 is-stream: 2.0.1 p-queue: 6.6.2 @@ -31024,16 +30248,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@theguild/federation-composition@0.20.2(graphql@16.12.0)': - dependencies: - constant-case: 3.0.4 - debug: 4.4.3(supports-color@8.1.1) - graphql: 16.12.0 - json5: 2.2.3 - lodash.sortby: 4.7.0 - transitivePeerDependencies: - - supports-color - '@theguild/federation-composition@0.20.2(graphql@16.9.0)': dependencies: constant-case: 3.0.4 @@ -34830,14 +34044,6 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 - form-data@4.0.4: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - hasown: 2.0.2 - mime-types: 2.1.35 - form-data@4.0.5: dependencies: asynckit: 0.4.0 @@ -35360,12 +34566,12 @@ snapshots: lodash.merge: 4.6.2 lodash.mergewith: 4.6.2 - graphql-jit@0.8.7(graphql@16.12.0): + graphql-jit@0.8.7(graphql@16.9.0): dependencies: - '@graphql-typed-document-node/core': 3.2.0(graphql@16.12.0) + '@graphql-typed-document-node/core': 3.2.0(graphql@16.9.0) fast-json-stringify: 5.16.1 generate-function: 2.3.1 - graphql: 16.12.0 + graphql: 16.9.0 lodash.memoize: 4.1.2 lodash.merge: 4.6.2 lodash.mergewith: 4.6.2 @@ -35514,23 +34720,6 @@ snapshots: optionalDependencies: ws: 8.18.0 - graphql-yoga@5.13.3(graphql@16.12.0): - dependencies: - '@envelop/core': 5.5.1 - '@envelop/instrumentation': 1.0.0 - '@graphql-tools/executor': 1.5.0(graphql@16.12.0) - '@graphql-tools/schema': 10.0.25(graphql@16.12.0) - '@graphql-tools/utils': 10.9.1(graphql@16.12.0) - '@graphql-yoga/logger': 2.0.1 - '@graphql-yoga/subscription': 5.0.5 - '@whatwg-node/fetch': 0.10.13 - '@whatwg-node/promise-helpers': 1.3.2 - '@whatwg-node/server': 0.10.17 - dset: 3.1.4 - graphql: 16.12.0 - lru-cache: 10.2.0 - tslib: 2.8.1 - graphql-yoga@5.13.3(graphql@16.9.0): dependencies: '@envelop/core': 5.5.1 @@ -35548,22 +34737,6 @@ snapshots: lru-cache: 10.2.0 tslib: 2.8.1 - graphql-yoga@5.17.1(graphql@16.12.0): - dependencies: - '@envelop/core': 5.5.1 - '@envelop/instrumentation': 1.0.0 - '@graphql-tools/executor': 1.5.0(graphql@16.12.0) - '@graphql-tools/schema': 10.0.25(graphql@16.12.0) - '@graphql-tools/utils': 10.11.0(graphql@16.12.0) - '@graphql-yoga/logger': 2.0.1 - '@graphql-yoga/subscription': 5.0.5 - '@whatwg-node/fetch': 0.10.13 - '@whatwg-node/promise-helpers': 1.3.2 - '@whatwg-node/server': 0.10.17 - graphql: 16.12.0 - lru-cache: 10.2.0 - tslib: 2.8.1 - graphql-yoga@5.17.1(graphql@16.9.0): dependencies: '@envelop/core': 5.5.1 @@ -38060,21 +37233,21 @@ snapshots: monaco-editor@0.52.2: {} - monaco-graphql@1.7.3(graphql@16.12.0)(monaco-editor@0.52.2)(prettier@3.8.1): + monaco-graphql@1.7.3(graphql@16.12.0)(monaco-editor@0.52.2)(prettier@3.4.2): dependencies: graphql: 16.12.0 graphql-language-service: 5.5.0(graphql@16.12.0) monaco-editor: 0.52.2 picomatch-browser: 2.2.6 - prettier: 3.8.1 + prettier: 3.4.2 - monaco-graphql@1.7.3(graphql@16.9.0)(monaco-editor@0.52.2)(prettier@3.8.1): + monaco-graphql@1.7.3(graphql@16.9.0)(monaco-editor@0.52.2)(prettier@3.4.2): dependencies: graphql: 16.9.0 graphql-language-service: 5.5.0(graphql@16.9.0) monaco-editor: 0.52.2 picomatch-browser: 2.2.6 - prettier: 3.8.1 + prettier: 3.4.2 monaco-themes@0.4.4: dependencies: @@ -38818,6 +37991,10 @@ snapshots: pg-connection-string@2.9.1: {} + pg-cursor@2.19.0(pg@8.13.1): + dependencies: + pg: 8.13.1 + pg-cursor@2.19.0(pg@8.20.0): dependencies: pg: 8.20.0 @@ -38836,12 +38013,12 @@ snapshots: dependencies: pg: 8.13.1 - pg-promise@11.10.2(pg-query-stream@4.14.0(pg@8.20.0)): + pg-promise@11.10.2(pg-query-stream@4.14.0(pg@8.13.1)): dependencies: assert-options: 0.8.2 pg: 8.13.1 pg-minify: 1.6.5 - pg-query-stream: 4.14.0(pg@8.20.0) + pg-query-stream: 4.14.0(pg@8.13.1) spex: 3.4.0 transitivePeerDependencies: - pg-native @@ -38850,6 +38027,11 @@ snapshots: pg-protocol@1.7.0: {} + pg-query-stream@4.14.0(pg@8.13.1): + dependencies: + pg: 8.13.1 + pg-cursor: 2.19.0(pg@8.13.1) + pg-query-stream@4.14.0(pg@8.20.0): dependencies: pg: 8.20.0 From 35cc93a53b27687145e8bb88394b0399f1cc982f Mon Sep 17 00:00:00 2001 From: Jonathan Brennan Date: Fri, 17 Apr 2026 10:21:45 -0500 Subject: [PATCH 15/18] address review issues, add plan-based retention, add integration tests --- integration-tests/testkit/flow.ts | 89 +++++++ integration-tests/testkit/seed.ts | 30 +++ .../tests/api/project/metric-alerts.spec.ts | 219 ++++++++++++++++++ .../providers/metric-alert-rules-storage.ts | 51 ++-- .../Mutation/updateMetricAlertRule.ts | 16 ++ .../src/lib/metric-alert-evaluator.ts | 21 +- .../src/lib/metric-alert-notifier.ts | 12 +- 7 files changed, 408 insertions(+), 30 deletions(-) create mode 100644 integration-tests/tests/api/project/metric-alerts.spec.ts diff --git a/integration-tests/testkit/flow.ts b/integration-tests/testkit/flow.ts index b34f1070e0..f3994680e2 100644 --- a/integration-tests/testkit/flow.ts +++ b/integration-tests/testkit/flow.ts @@ -2,6 +2,9 @@ import { graphql } from './gql'; import type { AddAlertChannelInput, AddAlertInput, + AddMetricAlertRuleInput, + DeleteMetricAlertRulesInput, + UpdateMetricAlertRuleInput, AnswerOrganizationTransferRequestInput, AssignMemberRoleInput, CreateMemberRoleInput, @@ -647,6 +650,92 @@ export function addAlert(input: AddAlertInput, authToken: string) { }); } +export function addMetricAlertRule(input: AddMetricAlertRuleInput, authToken: string) { + return execute({ + document: graphql(` + mutation IntegrationTests_AddMetricAlertRule($input: AddMetricAlertRuleInput!) { + addMetricAlertRule(input: $input) { + ok { + addedMetricAlertRule { + id + name + type + metric + thresholdType + thresholdValue + direction + severity + state + timeWindowMinutes + confirmationMinutes + enabled + channels { + id + name + type + } + } + } + error { + message + } + } + } + `), + variables: { input }, + authToken, + }); +} + +export function updateMetricAlertRule(input: UpdateMetricAlertRuleInput, authToken: string) { + return execute({ + document: graphql(` + mutation IntegrationTests_UpdateMetricAlertRule($input: UpdateMetricAlertRuleInput!) { + updateMetricAlertRule(input: $input) { + ok { + updatedMetricAlertRule { + id + name + type + metric + thresholdType + thresholdValue + direction + severity + state + enabled + } + } + error { + message + } + } + } + `), + variables: { input }, + authToken, + }); +} + +export function deleteMetricAlertRules(input: DeleteMetricAlertRulesInput, authToken: string) { + return execute({ + document: graphql(` + mutation IntegrationTests_DeleteMetricAlertRules($input: DeleteMetricAlertRulesInput!) { + deleteMetricAlertRules(input: $input) { + ok { + deletedMetricAlertRuleIds + } + error { + message + } + } + } + `), + variables: { input }, + authToken, + }); +} + export function readOrganizationInfo( selector: { organizationSlug: string; diff --git a/integration-tests/testkit/seed.ts b/integration-tests/testkit/seed.ts index 71821303a5..b4c98b57b7 100644 --- a/integration-tests/testkit/seed.ts +++ b/integration-tests/testkit/seed.ts @@ -19,6 +19,9 @@ import { ensureEnv } from './env'; import { addAlert, addAlertChannel, + addMetricAlertRule, + deleteMetricAlertRules, + updateMetricAlertRule, assignMemberRole, checkSchema, compareToPreviousVersion, @@ -814,6 +817,33 @@ export function initSeed() { ); return result.addAlertChannel; }, + async addMetricAlertRule( + input: { token?: string } & Parameters[0], + ) { + const result = await addMetricAlertRule( + input, + input.token || ownerToken, + ).then(r => r.expectNoGraphQLErrors()); + return result.addMetricAlertRule; + }, + async updateMetricAlertRule( + input: { token?: string } & Parameters[0], + ) { + const result = await updateMetricAlertRule( + input, + input.token || ownerToken, + ).then(r => r.expectNoGraphQLErrors()); + return result.updateMetricAlertRule; + }, + async deleteMetricAlertRules( + input: { token?: string } & Parameters[0], + ) { + const result = await deleteMetricAlertRules( + input, + input.token || ownerToken, + ).then(r => r.expectNoGraphQLErrors()); + return result.deleteMetricAlertRules; + }, /** * Create an access token for a given target. * This token can be used for usage reporting and all actions that would be performed by the CLI. diff --git a/integration-tests/tests/api/project/metric-alerts.spec.ts b/integration-tests/tests/api/project/metric-alerts.spec.ts new file mode 100644 index 0000000000..2c2dfb9fee --- /dev/null +++ b/integration-tests/tests/api/project/metric-alerts.spec.ts @@ -0,0 +1,219 @@ +import 'reflect-metadata'; +import { + AlertChannelType, + MetricAlertRuleDirection, + MetricAlertRuleMetric, + MetricAlertRuleSeverity, + MetricAlertRuleThresholdType, + MetricAlertRuleType, + ProjectType, +} from 'testkit/gql/graphql'; +import { initSeed } from '../../../testkit/seed'; + +test.concurrent('can create, read, update, and delete a metric alert rule', async ({ expect }) => { + const { createOrg } = await initSeed().createOwner(); + const { createProject, organization } = await createOrg(); + const { project, target, addAlertChannel, addMetricAlertRule, updateMetricAlertRule, deleteMetricAlertRules } = + await createProject(ProjectType.Single); + + const organizationSlug = organization.slug; + const projectSlug = project.slug; + const targetSlug = target.slug; + + // Create a webhook channel to attach to the rule + const channelResult = await addAlertChannel({ + name: 'test-webhook', + organizationSlug, + projectSlug, + type: AlertChannelType.Webhook, + webhook: { endpoint: 'http://localhost:9876/webhook' }, + }); + expect(channelResult.ok).toBeTruthy(); + const channelId = channelResult.ok!.addedAlertChannel.id; + + // Create a metric alert rule + const addResult = await addMetricAlertRule({ + organizationSlug, + projectSlug, + targetSlug, + name: 'P99 Latency Spike', + type: MetricAlertRuleType.Latency, + metric: MetricAlertRuleMetric.P99, + timeWindowMinutes: 30, + thresholdType: MetricAlertRuleThresholdType.FixedValue, + thresholdValue: 200, + direction: MetricAlertRuleDirection.Above, + severity: MetricAlertRuleSeverity.Critical, + channelIds: [channelId], + }); + expect(addResult.ok).toBeTruthy(); + expect(addResult.error).toBeNull(); + + const rule = addResult.ok!.addedMetricAlertRule; + expect(rule.name).toBe('P99 Latency Spike'); + expect(rule.type).toBe('LATENCY'); + expect(rule.metric).toBe('p99'); + expect(rule.thresholdType).toBe('FIXED_VALUE'); + expect(rule.thresholdValue).toBe(200); + expect(rule.direction).toBe('ABOVE'); + expect(rule.severity).toBe('CRITICAL'); + expect(rule.state).toBe('NORMAL'); + expect(rule.timeWindowMinutes).toBe(30); + expect(rule.confirmationMinutes).toBe(0); + expect(rule.enabled).toBe(true); + expect(rule.channels).toHaveLength(1); + expect(rule.channels[0].id).toBe(channelId); + + // Update the rule + const updateResult = await updateMetricAlertRule({ + organizationSlug, + projectSlug, + ruleId: rule.id, + name: 'Updated Latency Alert', + thresholdValue: 300, + severity: MetricAlertRuleSeverity.Warning, + }); + expect(updateResult.ok).toBeTruthy(); + expect(updateResult.error).toBeNull(); + + const updated = updateResult.ok!.updatedMetricAlertRule; + expect(updated.name).toBe('Updated Latency Alert'); + expect(updated.thresholdValue).toBe(300); + expect(updated.severity).toBe('WARNING'); + // Unchanged fields should persist + expect(updated.type).toBe('LATENCY'); + expect(updated.metric).toBe('p99'); + + // Delete the rule + const deleteResult = await deleteMetricAlertRules({ + organizationSlug, + projectSlug, + ruleIds: [rule.id], + }); + expect(deleteResult.ok).toBeTruthy(); + expect(deleteResult.ok!.deletedMetricAlertRuleIds).toContain(rule.id); +}); + +test.concurrent( + 'validates that LATENCY type requires metric and non-LATENCY rejects it', + async ({ expect }) => { + const { createOrg } = await initSeed().createOwner(); + const { createProject, organization } = await createOrg(); + const { project, target, addAlertChannel, addMetricAlertRule } = + await createProject(ProjectType.Single); + + const organizationSlug = organization.slug; + const projectSlug = project.slug; + const targetSlug = target.slug; + + const channelResult = await addAlertChannel({ + name: 'test-webhook', + organizationSlug, + projectSlug, + type: AlertChannelType.Webhook, + webhook: { endpoint: 'http://localhost:9876/webhook' }, + }); + const channelId = channelResult.ok!.addedAlertChannel.id; + + // LATENCY without metric should fail + const noMetricResult = await addMetricAlertRule({ + organizationSlug, + projectSlug, + targetSlug, + name: 'Bad Latency Alert', + type: MetricAlertRuleType.Latency, + timeWindowMinutes: 30, + thresholdType: MetricAlertRuleThresholdType.FixedValue, + thresholdValue: 200, + direction: MetricAlertRuleDirection.Above, + severity: MetricAlertRuleSeverity.Warning, + channelIds: [channelId], + // metric intentionally omitted + }); + expect(noMetricResult.error).toBeTruthy(); + expect(noMetricResult.error!.message).toContain('Metric is required'); + + // ERROR_RATE with metric should fail + const withMetricResult = await addMetricAlertRule({ + organizationSlug, + projectSlug, + targetSlug, + name: 'Bad Error Rate Alert', + type: MetricAlertRuleType.ErrorRate, + metric: MetricAlertRuleMetric.P99, + timeWindowMinutes: 30, + thresholdType: MetricAlertRuleThresholdType.PercentageChange, + thresholdValue: 50, + direction: MetricAlertRuleDirection.Above, + severity: MetricAlertRuleSeverity.Warning, + channelIds: [channelId], + }); + expect(withMetricResult.error).toBeTruthy(); + expect(withMetricResult.error!.message).toContain('should only be set for LATENCY'); + }, +); + +test.concurrent('requires at least one channel', async ({ expect }) => { + const { createOrg } = await initSeed().createOwner(); + const { createProject, organization } = await createOrg(); + const { project, target, addMetricAlertRule } = await createProject(ProjectType.Single); + + const result = await addMetricAlertRule({ + organizationSlug: organization.slug, + projectSlug: project.slug, + targetSlug: target.slug, + name: 'No Channels Alert', + type: MetricAlertRuleType.Traffic, + timeWindowMinutes: 5, + thresholdType: MetricAlertRuleThresholdType.FixedValue, + thresholdValue: 1000000, + direction: MetricAlertRuleDirection.Above, + severity: MetricAlertRuleSeverity.Info, + channelIds: [], + }); + expect(result.error).toBeTruthy(); + expect(result.error!.message).toContain('At least one channel'); +}); + +test.concurrent('supports multiple channels on a single rule', async ({ expect }) => { + const { createOrg } = await initSeed().createOwner(); + const { createProject, organization } = await createOrg(); + const { project, target, addAlertChannel, addMetricAlertRule } = + await createProject(ProjectType.Single); + + const organizationSlug = organization.slug; + const projectSlug = project.slug; + + // Create two channels + const channel1 = await addAlertChannel({ + name: 'webhook-1', + organizationSlug, + projectSlug, + type: AlertChannelType.Webhook, + webhook: { endpoint: 'http://localhost:9876/webhook1' }, + }); + const channel2 = await addAlertChannel({ + name: 'webhook-2', + organizationSlug, + projectSlug, + type: AlertChannelType.Webhook, + webhook: { endpoint: 'http://localhost:9876/webhook2' }, + }); + + const result = await addMetricAlertRule({ + organizationSlug, + projectSlug, + targetSlug: target.slug, + name: 'Multi-Channel Alert', + type: MetricAlertRuleType.Traffic, + timeWindowMinutes: 5, + thresholdType: MetricAlertRuleThresholdType.PercentageChange, + thresholdValue: 30, + direction: MetricAlertRuleDirection.Above, + severity: MetricAlertRuleSeverity.Warning, + channelIds: [channel1.ok!.addedAlertChannel.id, channel2.ok!.addedAlertChannel.id], + }); + + expect(result.ok).toBeTruthy(); + expect(result.ok!.addedMetricAlertRule.channels).toHaveLength(2); +}); diff --git a/packages/services/api/src/modules/alerts/providers/metric-alert-rules-storage.ts b/packages/services/api/src/modules/alerts/providers/metric-alert-rules-storage.ts index 15ac4f1639..e4f2555ccd 100644 --- a/packages/services/api/src/modules/alerts/providers/metric-alert-rules-storage.ts +++ b/packages/services/api/src/modules/alerts/providers/metric-alert-rules-storage.ts @@ -8,29 +8,34 @@ import type { MetricAlertStateLogEntry, } from '../../../shared/entities'; -const MetricAlertRuleModel = zod.object({ - id: zod.string(), - organizationId: zod.string(), - projectId: zod.string(), - targetId: zod.string(), - type: zod.enum(['LATENCY', 'ERROR_RATE', 'TRAFFIC']), - timeWindowMinutes: zod.number(), - metric: zod.enum(['avg', 'p75', 'p90', 'p95', 'p99']).nullable(), - thresholdType: zod.enum(['FIXED_VALUE', 'PERCENTAGE_CHANGE']), - thresholdValue: zod.number(), - direction: zod.enum(['ABOVE', 'BELOW']), - severity: zod.enum(['INFO', 'WARNING', 'CRITICAL']), - name: zod.string(), - createdAt: zod.string(), - updatedAt: zod.string(), - enabled: zod.boolean(), - lastEvaluatedAt: zod.string().nullable(), - lastTriggeredAt: zod.string().nullable(), - state: zod.enum(['NORMAL', 'PENDING', 'FIRING', 'RECOVERING']), - stateChangedAt: zod.string().nullable(), - confirmationMinutes: zod.number(), - savedFilterId: zod.string().nullable(), -}); +const MetricAlertRuleModel = zod + .object({ + id: zod.string(), + organizationId: zod.string(), + projectId: zod.string(), + targetId: zod.string(), + type: zod.enum(['LATENCY', 'ERROR_RATE', 'TRAFFIC']), + timeWindowMinutes: zod.number(), + metric: zod.enum(['avg', 'p75', 'p90', 'p95', 'p99']).nullable(), + thresholdType: zod.enum(['FIXED_VALUE', 'PERCENTAGE_CHANGE']), + thresholdValue: zod.number(), + direction: zod.enum(['ABOVE', 'BELOW']), + severity: zod.enum(['INFO', 'WARNING', 'CRITICAL']), + name: zod.string(), + createdAt: zod.string(), + updatedAt: zod.string(), + enabled: zod.boolean(), + lastEvaluatedAt: zod.string().nullable(), + lastTriggeredAt: zod.string().nullable(), + state: zod.enum(['NORMAL', 'PENDING', 'FIRING', 'RECOVERING']), + stateChangedAt: zod.string().nullable(), + confirmationMinutes: zod.number(), + savedFilterId: zod.string().nullable(), + }) + .refine( + data => (data.type === 'LATENCY') === (data.metric !== null), + { message: 'metric must be set for LATENCY type and null for other types' }, + ); const MetricAlertIncidentModel = zod.object({ id: zod.string(), diff --git a/packages/services/api/src/modules/alerts/resolvers/Mutation/updateMetricAlertRule.ts b/packages/services/api/src/modules/alerts/resolvers/Mutation/updateMetricAlertRule.ts index d95b0e9bc2..b0847453cc 100644 --- a/packages/services/api/src/modules/alerts/resolvers/Mutation/updateMetricAlertRule.ts +++ b/packages/services/api/src/modules/alerts/resolvers/Mutation/updateMetricAlertRule.ts @@ -27,6 +27,22 @@ export const updateMetricAlertRule: NonNullable< }; } + // Validate metric constraint against the effective type after update + const effectiveType = input.type ?? existing.type; + const effectiveMetric = input.metric !== undefined ? input.metric : existing.metric; + + if (effectiveType === 'LATENCY' && !effectiveMetric) { + return { + error: { message: 'Metric is required for LATENCY alert type.' }, + }; + } + + if (effectiveType !== 'LATENCY' && effectiveMetric) { + return { + error: { message: 'Metric should only be set for LATENCY alert type.' }, + }; + } + const rule = await storage.updateMetricAlertRule({ id: input.ruleId, type: input.type ?? undefined, diff --git a/packages/services/workflows/src/lib/metric-alert-evaluator.ts b/packages/services/workflows/src/lib/metric-alert-evaluator.ts index 0e239cf2b9..0abec6893c 100644 --- a/packages/services/workflows/src/lib/metric-alert-evaluator.ts +++ b/packages/services/workflows/src/lib/metric-alert-evaluator.ts @@ -90,6 +90,23 @@ function isThresholdBreached( : compareValue < rule.thresholdValue; } +const ALERT_STATE_LOG_RETENTION_DAYS: Record = { + HOBBY: 7, + PRO: 7, + ENTERPRISE: 30, +}; + +async function getAlertStateLogRetentionDays( + pg: PostgresDatabasePool, + organizationId: string, +): Promise { + const result = await pg.maybeOneFirst(psql` + SELECT "plan_name" FROM "organizations" WHERE "id" = ${organizationId} + `); + const planName = typeof result === 'string' ? result : 'HOBBY'; + return ALERT_STATE_LOG_RETENTION_DAYS[planName] ?? 7; +} + function hasElapsed(stateChangedAt: string | null, minutes: number): boolean { if (!stateChangedAt) return true; const changedAt = new Date(stateChangedAt).getTime(); @@ -332,8 +349,8 @@ async function logTransition( value: number, previousValue: number, ) { - // TODO: Look up org plan to determine expires_at (7d hobby/pro, 30d enterprise) - const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); + const retentionDays = await getAlertStateLogRetentionDays(pg, rule.organizationId); + const expiresAt = new Date(Date.now() + retentionDays * 24 * 60 * 60 * 1000); await pg.query(psql` INSERT INTO "metric_alert_state_log" ( diff --git a/packages/services/workflows/src/lib/metric-alert-notifier.ts b/packages/services/workflows/src/lib/metric-alert-notifier.ts index c371471092..84ab320ca3 100644 --- a/packages/services/workflows/src/lib/metric-alert-notifier.ts +++ b/packages/services/workflows/src/lib/metric-alert-notifier.ts @@ -1,4 +1,3 @@ -import got from 'got'; import type { Logger } from '@graphql-hive/logger'; import type { PostgresDatabasePool } from '@hive/postgres'; import { psql } from '@hive/postgres'; @@ -69,7 +68,7 @@ export async function sendMetricAlertNotifications(args: { break; } case 'MSTEAMS_WEBHOOK': { - await sendTeamsNotification({ channel, event, logger }); + await sendTeamsNotification({ channel, event, requestBroker: args.requestBroker, logger }); break; } } @@ -175,6 +174,7 @@ async function sendWebhookNotification(args: { async function sendTeamsNotification(args: { channel: AlertChannelRow; event: NotificationEvent; + requestBroker: RequestBroker | null; logger: Logger; }) { const { channel, event, logger } = args; @@ -209,9 +209,11 @@ async function sendTeamsNotification(args: { ], }; - await got.post(channel.webhookEndpoint, { - json: card, - timeout: { request: 10_000 }, + await sendWebhook(logger, args.requestBroker, { + attempt: 0, + maxAttempts: 5, + endpoint: channel.webhookEndpoint, + data: card, }); logger.debug({ channelId: channel.id }, 'Teams notification sent'); From df87dd6a4418cdaba16951acf72ab1f409e5db12 Mon Sep 17 00:00:00 2001 From: Jonathan Brennan Date: Fri, 17 Apr 2026 10:34:10 -0500 Subject: [PATCH 16/18] move Project.metricAlertRules to Target, add created_by_user_id --- integration-tests/testkit/flow.ts | 4 +- integration-tests/testkit/seed.ts | 18 ++++--- .../tests/api/project/metric-alerts.spec.ts | 20 +++++--- .../2026.04.15T00-00-01.metric-alert-rules.ts | 1 + .../api/src/modules/alerts/module.graphql.ts | 47 ++++++++++++++----- .../providers/metric-alert-rules-storage.ts | 23 ++++----- .../alerts/resolvers/MetricAlertRule.ts | 7 +++ .../resolvers/Mutation/addMetricAlertRule.ts | 5 +- .../src/modules/alerts/resolvers/Project.ts | 8 +--- .../src/modules/alerts/resolvers/Target.ts | 7 ++- packages/services/api/src/shared/entities.ts | 1 + .../src/lib/metric-alert-notifier.ts | 17 +++++-- .../tasks/purge-expired-alert-state-log.ts | 5 +- 13 files changed, 104 insertions(+), 59 deletions(-) diff --git a/integration-tests/testkit/flow.ts b/integration-tests/testkit/flow.ts index f3994680e2..478bf0f81a 100644 --- a/integration-tests/testkit/flow.ts +++ b/integration-tests/testkit/flow.ts @@ -3,8 +3,6 @@ import type { AddAlertChannelInput, AddAlertInput, AddMetricAlertRuleInput, - DeleteMetricAlertRulesInput, - UpdateMetricAlertRuleInput, AnswerOrganizationTransferRequestInput, AssignMemberRoleInput, CreateMemberRoleInput, @@ -14,6 +12,7 @@ import type { CreateTargetInput, CreateTokenInput, DeleteMemberRoleInput, + DeleteMetricAlertRulesInput, DeleteTokensInput, Experimental__UpdateTargetSchemaCompositionInput, InviteToOrganizationByEmailInput, @@ -27,6 +26,7 @@ import type { TargetSelectorInput, UpdateBaseSchemaInput, UpdateMemberRoleInput, + UpdateMetricAlertRuleInput, UpdateOrganizationSlugInput, UpdateProjectSlugInput, UpdateSchemaCompositionInput, diff --git a/integration-tests/testkit/seed.ts b/integration-tests/testkit/seed.ts index b4c98b57b7..b98d16a2b1 100644 --- a/integration-tests/testkit/seed.ts +++ b/integration-tests/testkit/seed.ts @@ -20,8 +20,6 @@ import { addAlert, addAlertChannel, addMetricAlertRule, - deleteMetricAlertRules, - updateMetricAlertRule, assignMemberRole, checkSchema, compareToPreviousVersion, @@ -33,6 +31,7 @@ import { createTarget, createToken, deleteMemberRole, + deleteMetricAlertRules, deleteSchema, deleteTokens, fetchLatestSchema, @@ -54,6 +53,7 @@ import { readTokenInfo, updateBaseSchema, updateMemberRole, + updateMetricAlertRule, updateTargetValidationSettings, } from './flow'; import * as GraphQLSchema from './gql/graphql'; @@ -820,19 +820,17 @@ export function initSeed() { async addMetricAlertRule( input: { token?: string } & Parameters[0], ) { - const result = await addMetricAlertRule( - input, - input.token || ownerToken, - ).then(r => r.expectNoGraphQLErrors()); + const result = await addMetricAlertRule(input, input.token || ownerToken).then( + r => r.expectNoGraphQLErrors(), + ); return result.addMetricAlertRule; }, async updateMetricAlertRule( input: { token?: string } & Parameters[0], ) { - const result = await updateMetricAlertRule( - input, - input.token || ownerToken, - ).then(r => r.expectNoGraphQLErrors()); + const result = await updateMetricAlertRule(input, input.token || ownerToken).then( + r => r.expectNoGraphQLErrors(), + ); return result.updateMetricAlertRule; }, async deleteMetricAlertRules( diff --git a/integration-tests/tests/api/project/metric-alerts.spec.ts b/integration-tests/tests/api/project/metric-alerts.spec.ts index 2c2dfb9fee..64afb43326 100644 --- a/integration-tests/tests/api/project/metric-alerts.spec.ts +++ b/integration-tests/tests/api/project/metric-alerts.spec.ts @@ -13,8 +13,14 @@ import { initSeed } from '../../../testkit/seed'; test.concurrent('can create, read, update, and delete a metric alert rule', async ({ expect }) => { const { createOrg } = await initSeed().createOwner(); const { createProject, organization } = await createOrg(); - const { project, target, addAlertChannel, addMetricAlertRule, updateMetricAlertRule, deleteMetricAlertRules } = - await createProject(ProjectType.Single); + const { + project, + target, + addAlertChannel, + addMetricAlertRule, + updateMetricAlertRule, + deleteMetricAlertRules, + } = await createProject(ProjectType.Single); const organizationSlug = organization.slug; const projectSlug = project.slug; @@ -99,8 +105,9 @@ test.concurrent( async ({ expect }) => { const { createOrg } = await initSeed().createOwner(); const { createProject, organization } = await createOrg(); - const { project, target, addAlertChannel, addMetricAlertRule } = - await createProject(ProjectType.Single); + const { project, target, addAlertChannel, addMetricAlertRule } = await createProject( + ProjectType.Single, + ); const organizationSlug = organization.slug; const projectSlug = project.slug; @@ -178,8 +185,9 @@ test.concurrent('requires at least one channel', async ({ expect }) => { test.concurrent('supports multiple channels on a single rule', async ({ expect }) => { const { createOrg } = await initSeed().createOwner(); const { createProject, organization } = await createOrg(); - const { project, target, addAlertChannel, addMetricAlertRule } = - await createProject(ProjectType.Single); + const { project, target, addAlertChannel, addMetricAlertRule } = await createProject( + ProjectType.Single, + ); const organizationSlug = organization.slug; const projectSlug = project.slug; diff --git a/packages/migrations/src/actions/2026.04.15T00-00-01.metric-alert-rules.ts b/packages/migrations/src/actions/2026.04.15T00-00-01.metric-alert-rules.ts index 1c97d2ab6e..b212848939 100644 --- a/packages/migrations/src/actions/2026.04.15T00-00-01.metric-alert-rules.ts +++ b/packages/migrations/src/actions/2026.04.15T00-00-01.metric-alert-rules.ts @@ -15,6 +15,7 @@ CREATE TABLE "metric_alert_rules" ( "organization_id" uuid NOT NULL REFERENCES "organizations"("id") ON DELETE CASCADE, "project_id" uuid NOT NULL REFERENCES "projects"("id") ON DELETE CASCADE, "target_id" uuid NOT NULL REFERENCES "targets"("id") ON DELETE CASCADE, + "created_by_user_id" uuid REFERENCES "users"("id") ON DELETE SET NULL, "type" "metric_alert_type" NOT NULL, "time_window_minutes" integer NOT NULL DEFAULT 30, "metric" "metric_alert_metric", diff --git a/packages/services/api/src/modules/alerts/module.graphql.ts b/packages/services/api/src/modules/alerts/module.graphql.ts index 25d9679835..2d8da40632 100644 --- a/packages/services/api/src/modules/alerts/module.graphql.ts +++ b/packages/services/api/src/modules/alerts/module.graphql.ts @@ -199,7 +199,9 @@ export default gql` name: String! type: MetricAlertRuleType! target: Target! - """Destinations that receive notifications when this rule fires or resolves.""" + """ + Destinations that receive notifications when this rule fires or resolves. + """ channels: [AlertChannel!]! timeWindowMinutes: Int! metric: MetricAlertRuleMetric @@ -211,18 +213,31 @@ export default gql` confirmationMinutes: Int! enabled: Boolean! lastEvaluatedAt: DateTime - """Most recent time this rule transitioned PENDING → FIRING (null if never fired).""" + """ + Most recent time this rule transitioned PENDING → FIRING (null if never fired). + """ lastTriggeredAt: DateTime createdAt: DateTime! - """The saved filter that scopes this rule (null = applies to the whole target).""" + createdBy: User + """ + The saved filter that scopes this rule (null = applies to the whole target). + """ savedFilter: SavedFilter - """Count of state transitions logged for this rule in the given time range.""" + """ + Count of state transitions logged for this rule in the given time range. + """ eventCount(from: DateTime!, to: DateTime!): Int! - """The currently open incident, if any.""" + """ + The currently open incident, if any. + """ currentIncident: MetricAlertRuleIncident - """Past incidents for this alert rule.""" + """ + Past incidents for this alert rule. + """ incidentHistory(limit: Int, offset: Int): [MetricAlertRuleIncident!]! - """State change history for this rule (powers the state timeline).""" + """ + State change history for this rule (powers the state timeline). + """ stateLog(from: DateTime!, to: DateTime!): [MetricAlertRuleStateChange!]! } @@ -239,22 +254,30 @@ export default gql` id: ID! fromState: MetricAlertRuleState! toState: MetricAlertRuleState! - """Metric value in the current window at transition time.""" + """ + Metric value in the current window at transition time. + """ value: Float - """Metric value in the previous (comparison) window at transition time.""" + """ + Metric value in the previous (comparison) window at transition time. + """ previousValue: Float - """Threshold value snapshotted at transition time (survives rule edits).""" + """ + Threshold value snapshotted at transition time (survives rule edits). + """ thresholdValue: Float createdAt: DateTime! rule: MetricAlertRule! } extend type Target { - """State changes across all alert rules for this target (powers the alert events chart + list).""" + """ + State changes across all alert rules for this target (powers the alert events chart + list). + """ metricAlertRuleStateLog(from: DateTime!, to: DateTime!): [MetricAlertRuleStateChange!]! } - extend type Project { + extend type Target { metricAlertRules: [MetricAlertRule!]! } diff --git a/packages/services/api/src/modules/alerts/providers/metric-alert-rules-storage.ts b/packages/services/api/src/modules/alerts/providers/metric-alert-rules-storage.ts index e4f2555ccd..2ba3ef4e01 100644 --- a/packages/services/api/src/modules/alerts/providers/metric-alert-rules-storage.ts +++ b/packages/services/api/src/modules/alerts/providers/metric-alert-rules-storage.ts @@ -14,6 +14,7 @@ const MetricAlertRuleModel = zod organizationId: zod.string(), projectId: zod.string(), targetId: zod.string(), + createdByUserId: zod.string().nullable(), type: zod.enum(['LATENCY', 'ERROR_RATE', 'TRAFFIC']), timeWindowMinutes: zod.number(), metric: zod.enum(['avg', 'p75', 'p90', 'p95', 'p99']).nullable(), @@ -32,10 +33,9 @@ const MetricAlertRuleModel = zod confirmationMinutes: zod.number(), savedFilterId: zod.string().nullable(), }) - .refine( - data => (data.type === 'LATENCY') === (data.metric !== null), - { message: 'metric must be set for LATENCY type and null for other types' }, - ); + .refine(data => (data.type === 'LATENCY') === (data.metric !== null), { + message: 'metric must be set for LATENCY type and null for other types', + }); const MetricAlertIncidentModel = zod.object({ id: zod.string(), @@ -65,6 +65,7 @@ const METRIC_ALERT_RULE_SELECT = psql` , "organization_id" as "organizationId" , "project_id" as "projectId" , "target_id" as "targetId" + , "created_by_user_id" as "createdByUserId" , "type" , "time_window_minutes" as "timeWindowMinutes" , "metric" @@ -166,6 +167,7 @@ export class MetricAlertRulesStorage { organizationId: string; projectId: string; targetId: string; + createdByUserId: string | null; type: MetricAlertRule['type']; timeWindowMinutes: number; metric: MetricAlertRule['metric']; @@ -182,6 +184,7 @@ export class MetricAlertRulesStorage { "organization_id" , "project_id" , "target_id" + , "created_by_user_id" , "type" , "time_window_minutes" , "metric" @@ -197,6 +200,7 @@ export class MetricAlertRulesStorage { ${args.organizationId} , ${args.projectId} , ${args.targetId} + , ${args.createdByUserId} , ${args.type} , ${args.timeWindowMinutes} , ${args.metric} @@ -272,10 +276,7 @@ export class MetricAlertRulesStorage { // --- Rule Channels (many-to-many) --- - async setRuleChannels(args: { - ruleId: string; - channelIds: string[]; - }): Promise { + async setRuleChannels(args: { ruleId: string; channelIds: string[] }): Promise { await this.pool.transaction('setRuleChannels', async trx => { await trx.query(psql` DELETE FROM "metric_alert_rule_channels" @@ -475,11 +476,7 @@ export class MetricAlertRulesStorage { return result.map(row => MetricAlertStateLogModel.parse(row) as MetricAlertStateLogEntry); } - async getEventCount(args: { - ruleId: string; - from: Date; - to: Date; - }): Promise { + async getEventCount(args: { ruleId: string; from: Date; to: Date }): Promise { const result = await this.pool.oneFirst(psql`/* getEventCount */ SELECT count(*)::int FROM "metric_alert_state_log" diff --git a/packages/services/api/src/modules/alerts/resolvers/MetricAlertRule.ts b/packages/services/api/src/modules/alerts/resolvers/MetricAlertRule.ts index 4fa81b5a94..1638642568 100644 --- a/packages/services/api/src/modules/alerts/resolvers/MetricAlertRule.ts +++ b/packages/services/api/src/modules/alerts/resolvers/MetricAlertRule.ts @@ -1,10 +1,17 @@ import { SavedFiltersStorage } from '../../saved-filters/providers/saved-filters-storage'; +import { Storage } from '../../shared/providers/storage'; import { TargetManager } from '../../target/providers/target-manager'; import { AlertsManager } from '../providers/alerts-manager'; import { MetricAlertRulesStorage } from '../providers/metric-alert-rules-storage'; import type { MetricAlertRuleResolvers } from './../../../__generated__/types'; export const MetricAlertRule: MetricAlertRuleResolvers = { + createdBy: (rule, _, { injector }) => { + if (!rule.createdByUserId) { + return null; + } + return injector.get(Storage).getUserById({ id: rule.createdByUserId }); + }, target: (rule, _, { injector }) => { return injector.get(TargetManager).getTarget({ targetId: rule.targetId, diff --git a/packages/services/api/src/modules/alerts/resolvers/Mutation/addMetricAlertRule.ts b/packages/services/api/src/modules/alerts/resolvers/Mutation/addMetricAlertRule.ts index 752f4888a7..2e2112b446 100644 --- a/packages/services/api/src/modules/alerts/resolvers/Mutation/addMetricAlertRule.ts +++ b/packages/services/api/src/modules/alerts/resolvers/Mutation/addMetricAlertRule.ts @@ -6,7 +6,7 @@ import type { MutationResolvers } from './../../../../__generated__/types'; export const addMetricAlertRule: NonNullable = async ( _, { input }, - { injector }, + { injector, session }, ) => { const translator = injector.get(IdTranslator); const [organizationId, projectId, targetId] = await Promise.all([ @@ -21,6 +21,8 @@ export const addMetricAlertRule: NonNullable = { +export const Project: Pick = { alerts: async (project, _, { injector }) => { return injector.get(AlertsManager).getAlerts({ organizationId: project.orgId, @@ -15,9 +14,4 @@ export const Project: Pick { - return injector.get(MetricAlertRulesStorage).getMetricAlertRules({ - projectId: project.id, - }); - }, }; diff --git a/packages/services/api/src/modules/alerts/resolvers/Target.ts b/packages/services/api/src/modules/alerts/resolvers/Target.ts index 9104553830..22f5633cc3 100644 --- a/packages/services/api/src/modules/alerts/resolvers/Target.ts +++ b/packages/services/api/src/modules/alerts/resolvers/Target.ts @@ -1,7 +1,12 @@ import { MetricAlertRulesStorage } from '../providers/metric-alert-rules-storage'; import type { TargetResolvers } from './../../../__generated__/types'; -export const Target: Pick = { +export const Target: Pick = { + metricAlertRules: (target, _, { injector }) => { + return injector.get(MetricAlertRulesStorage).getMetricAlertRulesByTarget({ + targetId: target.id, + }); + }, metricAlertRuleStateLog: (target, { from, to }, { injector }) => { return injector.get(MetricAlertRulesStorage).getStateLogByTarget({ targetId: target.id, diff --git a/packages/services/api/src/shared/entities.ts b/packages/services/api/src/shared/entities.ts index 3e7b469e4d..cdda497237 100644 --- a/packages/services/api/src/shared/entities.ts +++ b/packages/services/api/src/shared/entities.ts @@ -448,6 +448,7 @@ export interface MetricAlertRule { organizationId: string; projectId: string; targetId: string; + createdByUserId: string | null; type: MetricAlertRuleType; timeWindowMinutes: number; metric: MetricAlertRuleMetric | null; diff --git a/packages/services/workflows/src/lib/metric-alert-notifier.ts b/packages/services/workflows/src/lib/metric-alert-notifier.ts index 84ab320ca3..6fb83c28fc 100644 --- a/packages/services/workflows/src/lib/metric-alert-notifier.ts +++ b/packages/services/workflows/src/lib/metric-alert-notifier.ts @@ -68,7 +68,12 @@ export async function sendMetricAlertNotifications(args: { break; } case 'MSTEAMS_WEBHOOK': { - await sendTeamsNotification({ channel, event, requestBroker: args.requestBroker, logger }); + await sendTeamsNotification({ + channel, + event, + requestBroker: args.requestBroker, + logger, + }); break; } } @@ -223,11 +228,17 @@ function formatChangeText(event: NotificationEvent): string { const { rule, currentValue, previousValue } = event; const unit = rule.type === 'LATENCY' ? 'ms' : rule.type === 'ERROR_RATE' ? '%' : ' requests'; const metricLabel = - rule.type === 'LATENCY' ? `${rule.metric} latency` : rule.type === 'ERROR_RATE' ? 'Error rate' : 'Traffic'; + rule.type === 'LATENCY' + ? `${rule.metric} latency` + : rule.type === 'ERROR_RATE' + ? 'Error rate' + : 'Traffic'; if (event.state === 'firing') { const changePercent = - previousValue !== 0 ? (((currentValue - previousValue) / previousValue) * 100).toFixed(1) : 'N/A'; + previousValue !== 0 + ? (((currentValue - previousValue) / previousValue) * 100).toFixed(1) + : 'N/A'; return `${metricLabel}: **${currentValue.toFixed(2)}${unit}** (was ${previousValue.toFixed(2)}${unit}, ${changePercent}% change) — Threshold: ${rule.direction.toLowerCase()} ${rule.thresholdValue}${rule.thresholdType === 'PERCENTAGE_CHANGE' ? '%' : unit}`; } diff --git a/packages/services/workflows/src/tasks/purge-expired-alert-state-log.ts b/packages/services/workflows/src/tasks/purge-expired-alert-state-log.ts index dde60b57b1..f96f7db8ce 100644 --- a/packages/services/workflows/src/tasks/purge-expired-alert-state-log.ts +++ b/packages/services/workflows/src/tasks/purge-expired-alert-state-log.ts @@ -18,8 +18,5 @@ export const task = implementTask(PurgeExpiredAlertStateLogTask, async args => { SELECT COUNT(*)::int FROM "deleted"; `); const amount = z.number().parse(result); - args.logger.debug( - { purgedCount: amount }, - 'finished purging expired alert state log entries', - ); + args.logger.debug({ purgedCount: amount }, 'finished purging expired alert state log entries'); }); From 9ad4638a8d7c5aa79b599014373dec0093de918c Mon Sep 17 00:00:00 2001 From: Jonathan Brennan Date: Fri, 17 Apr 2026 15:07:30 -0500 Subject: [PATCH 17/18] add initial metric alerts seed script with 30 days history --- package.json | 1 + scripts/seed-metric-alerts.mts | 544 +++++++++++++++++++++++++++++++++ 2 files changed, 545 insertions(+) create mode 100644 scripts/seed-metric-alerts.mts diff --git a/package.json b/package.json index f7593d6a92..5298f25840 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "release:version": "changeset version && pnpm build:libraries && pnpm --filter @graphql-hive/cli oclif:readme", "seed:app-deployments": "tsx scripts/seed-app-deployments.mts", "seed:insights": "tsx scripts/seed-insights.mts", + "seed:metric-alerts": "tsx scripts/seed-metric-alerts.mts", "seed:org": "tsx scripts/seed-organization.mts", "seed:schemas": "tsx scripts/seed-schemas.ts", "seed:usage": "tsx scripts/seed-usage.ts", diff --git a/scripts/seed-metric-alerts.mts b/scripts/seed-metric-alerts.mts new file mode 100644 index 0000000000..d9687d9515 --- /dev/null +++ b/scripts/seed-metric-alerts.mts @@ -0,0 +1,544 @@ +/** + * Seeds metric alert rules with 30 days of historical data and 7 days of future data. + * + * Creates: alert channels, metric alert rules (all types), incidents, state log entries, + * and sets some rules to non-NORMAL states for testing polling/live updates. + * + * Prerequisites: + * - Docker Compose is running (pnpm local:setup) + * - Services are running (pnpm dev:hive) + * - Run seed:insights first to have an org/project/target with usage data + * + * Usage: + * pnpm seed:metric-alerts + */ + +import setCookie from 'set-cookie-parser'; +import { createPostgresDatabasePool, psql } from '@hive/postgres'; + +process.env.RUN_AGAINST_LOCAL_SERVICES = '1'; +await import('../integration-tests/local-dev.ts'); + +const { ensureEnv } = await import('../integration-tests/testkit/env'); +const { addAlertChannel, addMetricAlertRule } = await import('../integration-tests/testkit/flow'); +const { getServiceHost } = await import('../integration-tests/testkit/utils'); +const { + AlertChannelType, + MetricAlertRuleType, + MetricAlertRuleMetric, + MetricAlertRuleThresholdType, + MetricAlertRuleDirection, + MetricAlertRuleSeverity, +} = await import('../integration-tests/testkit/gql/graphql'); + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- + +const OWNER_EMAIL = 'alerts-seed@local.dev'; +const PASSWORD = 'ilikebigturtlesandicannotlie47'; +const DAYS_PAST = 30; +const DAYS_AHEAD = 7; + +// --------------------------------------------------------------------------- +// Auth +// --------------------------------------------------------------------------- + +async function signInOrSignUp( + email: string, +): Promise<{ access_token: string; refresh_token: string }> { + const graphqlAddress = await getServiceHost('server', 8082); + + let response = await fetch(`http://${graphqlAddress}/auth-api/signup`, { + method: 'POST', + body: JSON.stringify({ + formFields: [ + { id: 'email', value: email }, + { id: 'password', value: PASSWORD }, + ], + }), + headers: { 'content-type': 'application/json' }, + }); + + let body = await response.json(); + if (body.status === 'OK') { + const cookies = setCookie.parse(response.headers.getSetCookie()); + return { + access_token: cookies.find(c => c.name === 'sAccessToken')?.value ?? '', + refresh_token: cookies.find(c => c.name === 'sRefreshToken')?.value ?? '', + }; + } + + response = await fetch(`http://${graphqlAddress}/auth-api/signin`, { + method: 'POST', + body: JSON.stringify({ + formFields: [ + { id: 'email', value: email }, + { id: 'password', value: PASSWORD }, + ], + }), + headers: { 'content-type': 'application/json' }, + }); + + body = await response.json(); + if (body.status === 'OK') { + const cookies = setCookie.parse(response.headers.getSetCookie()); + return { + access_token: cookies.find(c => c.name === 'sAccessToken')?.value ?? '', + refresh_token: cookies.find(c => c.name === 'sRefreshToken')?.value ?? '', + }; + } + + throw new Error('Failed to sign in or up: ' + JSON.stringify(body, null, 2)); +} + +// --------------------------------------------------------------------------- +// DB helpers +// --------------------------------------------------------------------------- + +function getPGConnectionString() { + const pg = { + user: ensureEnv('POSTGRES_USER'), + password: ensureEnv('POSTGRES_PASSWORD'), + host: ensureEnv('POSTGRES_HOST'), + port: ensureEnv('POSTGRES_PORT'), + db: ensureEnv('POSTGRES_DB'), + }; + return `postgres://${pg.user}:${pg.password}@${pg.host}:${pg.port}/${pg.db}?sslmode=disable`; +} + +function hoursAgo(hours: number): Date { + return new Date(Date.now() - hours * 60 * 60 * 1000); +} + +function hoursAhead(hours: number): Date { + return new Date(Date.now() + hours * 60 * 60 * 1000); +} + +function randomBetween(min: number, max: number): number { + return Math.random() * (max - min) + min; +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main() { + console.log('🚨 Seeding metric alert rules...\n'); + + // 1. Find the first org/project/target that has usage data + const pool = await createPostgresDatabasePool({ + connectionParameters: getPGConnectionString(), + }); + + const existingTarget = await pool.maybeOne(psql` + SELECT + t."id" as "targetId", + t."clean_id" as "targetSlug", + p."id" as "projectId", + p."clean_id" as "projectSlug", + o."id" as "organizationId", + o."clean_id" as "organizationSlug" + FROM "targets" t + INNER JOIN "projects" p ON p."id" = t."project_id" + INNER JOIN "organizations" o ON o."id" = p."org_id" + ORDER BY t."created_at" DESC + LIMIT 1 + `); + + if (!existingTarget) { + console.error('❌ No targets found. Run seed:insights first.'); + process.exit(1); + } + + const { targetId, targetSlug, projectId, projectSlug, organizationId, organizationSlug } = + existingTarget as { + targetId: string; + targetSlug: string; + projectId: string; + projectSlug: string; + organizationId: string; + organizationSlug: string; + }; + + console.log(`📍 Using target: ${organizationSlug}/${projectSlug}/${targetSlug}`); + + // 2. Auth + const auth = await signInOrSignUp(OWNER_EMAIL); + const token = auth.access_token; + console.log(`🔑 Authenticated as ${OWNER_EMAIL}`); + + // 3. Create alert channels + console.log('\n📡 Creating alert channels...'); + + const slackChannel = await addAlertChannel( + { + organizationSlug: organizationSlug as string, + projectSlug: projectSlug as string, + name: 'Slack #alerts', + type: AlertChannelType.Slack, + slack: { channel: '#alerts' }, + }, + token, + ).then(r => r.expectNoGraphQLErrors()); + + const webhookChannel = await addAlertChannel( + { + organizationSlug: organizationSlug as string, + projectSlug: projectSlug as string, + name: 'PagerDuty Webhook', + type: AlertChannelType.Webhook, + webhook: { endpoint: 'https://events.pagerduty.com/v2/enqueue' }, + }, + token, + ).then(r => r.expectNoGraphQLErrors()); + + const slackChannelId = slackChannel.addAlertChannel.ok!.addedAlertChannel.id; + const webhookChannelId = webhookChannel.addAlertChannel.ok!.addedAlertChannel.id; + console.log(` Created: Slack #alerts (${slackChannelId})`); + console.log(` Created: PagerDuty Webhook (${webhookChannelId})`); + + // 4. Create metric alert rules + console.log('\n📏 Creating metric alert rules...'); + + const ruleDefs = [ + { + name: 'Error Rate Above 10% - Last 5 Min', + type: MetricAlertRuleType.ErrorRate, + metric: null, + timeWindowMinutes: 5, + thresholdType: MetricAlertRuleThresholdType.FixedValue, + thresholdValue: 10, + direction: MetricAlertRuleDirection.Above, + severity: MetricAlertRuleSeverity.Critical, + channelIds: [slackChannelId, webhookChannelId], + // Will be set to FIRING + desiredState: 'FIRING' as const, + }, + { + name: 'Error Rate Above 5%', + type: MetricAlertRuleType.ErrorRate, + metric: null, + timeWindowMinutes: 60, + thresholdType: MetricAlertRuleThresholdType.FixedValue, + thresholdValue: 5, + direction: MetricAlertRuleDirection.Above, + severity: MetricAlertRuleSeverity.Info, + channelIds: [slackChannelId, webhookChannelId], + desiredState: 'FIRING' as const, + }, + { + name: 'Error Rate Increased by 75% - Last Hour', + type: MetricAlertRuleType.ErrorRate, + metric: null, + timeWindowMinutes: 60, + thresholdType: MetricAlertRuleThresholdType.PercentageChange, + thresholdValue: 75, + direction: MetricAlertRuleDirection.Above, + severity: MetricAlertRuleSeverity.Critical, + channelIds: [slackChannelId], + desiredState: 'NORMAL' as const, + }, + { + name: 'P99 Latency Above 2000ms', + type: MetricAlertRuleType.Latency, + metric: MetricAlertRuleMetric.P99, + timeWindowMinutes: 30, + thresholdType: MetricAlertRuleThresholdType.FixedValue, + thresholdValue: 2000, + direction: MetricAlertRuleDirection.Above, + severity: MetricAlertRuleSeverity.Warning, + channelIds: [slackChannelId, webhookChannelId], + desiredState: 'FIRING' as const, + }, + { + name: 'P95 Latency Increased by 25%', + type: MetricAlertRuleType.Latency, + metric: MetricAlertRuleMetric.P95, + timeWindowMinutes: 30, + thresholdType: MetricAlertRuleThresholdType.PercentageChange, + thresholdValue: 25, + direction: MetricAlertRuleDirection.Above, + severity: MetricAlertRuleSeverity.Warning, + channelIds: [webhookChannelId], + desiredState: 'NORMAL' as const, + }, + { + name: 'P99 Latency Increased by 50%', + type: MetricAlertRuleType.Latency, + metric: MetricAlertRuleMetric.P99, + timeWindowMinutes: 60, + thresholdType: MetricAlertRuleThresholdType.PercentageChange, + thresholdValue: 50, + direction: MetricAlertRuleDirection.Above, + severity: MetricAlertRuleSeverity.Critical, + channelIds: [slackChannelId], + desiredState: 'NORMAL' as const, + }, + { + name: 'Request Rate Below 100 rpm', + type: MetricAlertRuleType.Traffic, + metric: null, + timeWindowMinutes: 30, + thresholdType: MetricAlertRuleThresholdType.FixedValue, + thresholdValue: 100, + direction: MetricAlertRuleDirection.Below, + severity: MetricAlertRuleSeverity.Critical, + channelIds: [slackChannelId, webhookChannelId], + desiredState: 'NORMAL' as const, + }, + { + name: 'Traffic Decreased by 60% - Last Hour', + type: MetricAlertRuleType.Traffic, + metric: null, + timeWindowMinutes: 60, + thresholdType: MetricAlertRuleThresholdType.PercentageChange, + thresholdValue: 60, + direction: MetricAlertRuleDirection.Below, + severity: MetricAlertRuleSeverity.Warning, + channelIds: [webhookChannelId], + desiredState: 'NORMAL' as const, + }, + { + name: 'Traffic Increased by 150% - Last 30 Min', + type: MetricAlertRuleType.Traffic, + metric: null, + timeWindowMinutes: 30, + thresholdType: MetricAlertRuleThresholdType.PercentageChange, + thresholdValue: 150, + direction: MetricAlertRuleDirection.Above, + severity: MetricAlertRuleSeverity.Info, + channelIds: [slackChannelId, webhookChannelId], + desiredState: 'FIRING' as const, + }, + { + name: 'Traffic Increased by 200% - Last 15 Min', + type: MetricAlertRuleType.Traffic, + metric: null, + timeWindowMinutes: 15, + thresholdType: MetricAlertRuleThresholdType.PercentageChange, + thresholdValue: 200, + direction: MetricAlertRuleDirection.Above, + severity: MetricAlertRuleSeverity.Warning, + channelIds: [webhookChannelId], + desiredState: 'PENDING' as const, + }, + { + name: 'Request Rate Above 10,000 rpm', + type: MetricAlertRuleType.Traffic, + metric: null, + timeWindowMinutes: 5, + thresholdType: MetricAlertRuleThresholdType.FixedValue, + thresholdValue: 10000, + direction: MetricAlertRuleDirection.Above, + severity: MetricAlertRuleSeverity.Warning, + channelIds: [slackChannelId], + desiredState: 'RECOVERING' as const, + }, + ]; + + const createdRules: Array<{ id: string; name: string; desiredState: string }> = []; + + for (const def of ruleDefs) { + const result = await addMetricAlertRule( + { + organizationSlug: organizationSlug as string, + projectSlug: projectSlug as string, + targetSlug: targetSlug as string, + name: def.name, + type: def.type, + metric: def.metric ?? undefined, + timeWindowMinutes: def.timeWindowMinutes, + thresholdType: def.thresholdType, + thresholdValue: def.thresholdValue, + direction: def.direction, + severity: def.severity, + channelIds: def.channelIds, + }, + token, + ).then(r => r.expectNoGraphQLErrors()); + + const rule = result.addMetricAlertRule.ok?.addedMetricAlertRule; + if (rule) { + createdRules.push({ id: rule.id, name: def.name, desiredState: def.desiredState }); + console.log(` ✓ ${def.name} (${rule.id})`); + } else { + console.error(` ✗ Failed to create: ${def.name}`); + } + } + + // 5. Seed state transitions, incidents, and set desired states + console.log('\n📊 Seeding 30 days of historical data + 7 days ahead...'); + + const totalHours = (DAYS_PAST + DAYS_AHEAD) * 24; + const nowHoursFromStart = DAYS_PAST * 24; + + for (const rule of createdRules) { + // Generate realistic state transition history + const transitions: Array<{ + fromState: string; + toState: string; + value: number; + previousValue: number; + thresholdValue: number; + createdAt: Date; + }> = []; + + let currentState = 'NORMAL'; + let hour = 0; + + while (hour < totalHours) { + // Random interval between events (4-48 hours) + hour += Math.floor(randomBetween(4, 48)); + if (hour >= totalHours) break; + + const eventTime = hoursAgo(nowHoursFromStart - hour); + const value = randomBetween(50, 500); + const previousValue = randomBetween(30, 300); + + // Simulate a firing cycle: NORMAL → PENDING → FIRING → RECOVERING → NORMAL + if (currentState === 'NORMAL') { + transitions.push({ + fromState: 'NORMAL', + toState: 'PENDING', + value, + previousValue, + thresholdValue: value * 0.8, + createdAt: eventTime, + }); + currentState = 'PENDING'; + + // PENDING → FIRING after a few minutes + const firingTime = new Date(eventTime.getTime() + randomBetween(2, 10) * 60000); + transitions.push({ + fromState: 'PENDING', + toState: 'FIRING', + value: value * 1.1, + previousValue: value, + thresholdValue: value * 0.8, + createdAt: firingTime, + }); + currentState = 'FIRING'; + + // FIRING → RECOVERING after some time + const recoverTime = new Date( + firingTime.getTime() + randomBetween(10, 180) * 60000, + ); + transitions.push({ + fromState: 'FIRING', + toState: 'RECOVERING', + value: value * 0.5, + previousValue: value * 1.1, + thresholdValue: value * 0.8, + createdAt: recoverTime, + }); + currentState = 'RECOVERING'; + + // RECOVERING → NORMAL after confirmation + const normalTime = new Date( + recoverTime.getTime() + randomBetween(3, 15) * 60000, + ); + transitions.push({ + fromState: 'RECOVERING', + toState: 'NORMAL', + value: value * 0.3, + previousValue: value * 0.5, + thresholdValue: value * 0.8, + createdAt: normalTime, + }); + currentState = 'NORMAL'; + } + } + + // Insert state log entries + const expiresAt = hoursAhead(DAYS_AHEAD * 24 + 24); + for (const t of transitions) { + await pool.query(psql` + INSERT INTO "metric_alert_state_log" ( + "metric_alert_rule_id", "target_id", "from_state", "to_state", + "value", "previous_value", "threshold_value", "created_at", "expires_at" + ) VALUES ( + ${rule.id}, ${targetId as string}, ${t.fromState}, ${t.toState}, + ${t.value}, ${t.previousValue}, ${t.thresholdValue}, + ${t.createdAt.toISOString()}, ${expiresAt.toISOString()} + ) + `); + } + + // Create incidents from FIRING transitions + const firingTransitions = transitions.filter(t => t.toState === 'FIRING'); + const resolvedTransitions = transitions.filter(t => t.fromState === 'RECOVERING' && t.toState === 'NORMAL'); + + for (let i = 0; i < firingTransitions.length; i++) { + const firing = firingTransitions[i]; + const resolved = resolvedTransitions[i]; + await pool.query(psql` + INSERT INTO "metric_alert_incidents" ( + "metric_alert_rule_id", "started_at", "resolved_at", + "current_value", "previous_value", "threshold_value" + ) VALUES ( + ${rule.id}, ${firing.createdAt.toISOString()}, + ${resolved ? resolved.createdAt.toISOString() : null}, + ${firing.value}, ${firing.previousValue}, ${firing.thresholdValue} + ) + `); + } + + // Set desired state for live testing + if (rule.desiredState !== 'NORMAL') { + const stateChangedAt = hoursAgo(randomBetween(0.5, 3)); + await pool.query(psql` + UPDATE "metric_alert_rules" + SET + "state" = ${rule.desiredState}, + "state_changed_at" = ${stateChangedAt.toISOString()}, + "last_evaluated_at" = NOW(), + "updated_at" = NOW() + WHERE "id" = ${rule.id} + `); + + // For FIRING rules, create an open incident + if (rule.desiredState === 'FIRING') { + await pool.query(psql` + INSERT INTO "metric_alert_incidents" ( + "metric_alert_rule_id", "started_at", + "current_value", "previous_value", "threshold_value" + ) VALUES ( + ${rule.id}, ${stateChangedAt.toISOString()}, + ${randomBetween(100, 500)}, ${randomBetween(30, 100)}, ${randomBetween(50, 200)} + ) + `); + } + + console.log(` 🔥 ${rule.name} → ${rule.desiredState}`); + } + + console.log( + ` 📈 ${rule.name}: ${transitions.length} state transitions, ${firingTransitions.length} incidents`, + ); + } + + await pool.end(); + + console.log(` +✅ Metric alerts seed complete! + +Credentials: + Email: ${OWNER_EMAIL} + Password: ${PASSWORD} + +Rules in non-NORMAL states (for polling testing): +${createdRules + .filter(r => r.desiredState !== 'NORMAL') + .map(r => ` ${r.desiredState.padEnd(12)} ${r.name}`) + .join('\n')} + +Navigate to: + http://localhost:3000/${organizationSlug}/${projectSlug}/${targetSlug}/alerts +`); +} + +main().catch(err => { + console.error('Seed failed:', err); + process.exit(1); +}); From 54d6c2d466955c40fbf6d0c30cff636ea9d4d06f Mon Sep 17 00:00:00 2001 From: Jonathan Brennan Date: Fri, 17 Apr 2026 18:26:40 -0500 Subject: [PATCH 18/18] scaffold alerts tab and sub routes --- .../web/app/src/components/layouts/target.tsx | 7 + .../app/src/pages/target-alerts-activity.tsx | 13 + .../app/src/pages/target-alerts-create.tsx | 13 + .../app/src/pages/target-alerts-detail.tsx | 18 + .../web/app/src/pages/target-alerts-rules.tsx | 13 + packages/web/app/src/pages/target-alerts.tsx | 57 + packages/web/app/src/router.tsx | 85 ++ pnpm-lock.yaml | 1217 ++++++++++++++--- 8 files changed, 1219 insertions(+), 204 deletions(-) create mode 100644 packages/web/app/src/pages/target-alerts-activity.tsx create mode 100644 packages/web/app/src/pages/target-alerts-create.tsx create mode 100644 packages/web/app/src/pages/target-alerts-detail.tsx create mode 100644 packages/web/app/src/pages/target-alerts-rules.tsx create mode 100644 packages/web/app/src/pages/target-alerts.tsx diff --git a/packages/web/app/src/components/layouts/target.tsx b/packages/web/app/src/components/layouts/target.tsx index 91b760102c..1c0497e8af 100644 --- a/packages/web/app/src/components/layouts/target.tsx +++ b/packages/web/app/src/components/layouts/target.tsx @@ -44,6 +44,7 @@ export enum Page { Laboratory = 'laboratory', Apps = 'apps', Proposals = 'proposals', + Alerts = 'alerts', Settings = 'settings', } @@ -216,6 +217,12 @@ export const TargetLayout = ({ to: '/$organizationSlug/$projectSlug/$targetSlug/proposals', params, }, + { + value: Page.Alerts, + label: 'Alerts', + to: '/$organizationSlug/$projectSlug/$targetSlug/alerts', + params, + }, { value: Page.Settings, label: 'Settings', diff --git a/packages/web/app/src/pages/target-alerts-activity.tsx b/packages/web/app/src/pages/target-alerts-activity.tsx new file mode 100644 index 0000000000..713b3581be --- /dev/null +++ b/packages/web/app/src/pages/target-alerts-activity.tsx @@ -0,0 +1,13 @@ +import { SubPageLayout, SubPageLayoutHeader } from '@/components/ui/page-content-layout'; + +export function TargetAlertsActivityPage() { + return ( + + +

Alert activity coming soon.

+
+ ); +} diff --git a/packages/web/app/src/pages/target-alerts-create.tsx b/packages/web/app/src/pages/target-alerts-create.tsx new file mode 100644 index 0000000000..49a5585947 --- /dev/null +++ b/packages/web/app/src/pages/target-alerts-create.tsx @@ -0,0 +1,13 @@ +import { SubPageLayout, SubPageLayoutHeader } from '@/components/ui/page-content-layout'; + +export function TargetAlertsCreatePage() { + return ( + + +

Create alert form coming soon.

+
+ ); +} diff --git a/packages/web/app/src/pages/target-alerts-detail.tsx b/packages/web/app/src/pages/target-alerts-detail.tsx new file mode 100644 index 0000000000..e633e7c1aa --- /dev/null +++ b/packages/web/app/src/pages/target-alerts-detail.tsx @@ -0,0 +1,18 @@ +import { SubPageLayout, SubPageLayoutHeader } from '@/components/ui/page-content-layout'; + +export function TargetAlertsDetailPage(props: { + organizationSlug: string; + projectSlug: string; + targetSlug: string; + ruleId: string; +}) { + return ( + + +

Alert detail view coming soon.

+
+ ); +} diff --git a/packages/web/app/src/pages/target-alerts-rules.tsx b/packages/web/app/src/pages/target-alerts-rules.tsx new file mode 100644 index 0000000000..6ece15e386 --- /dev/null +++ b/packages/web/app/src/pages/target-alerts-rules.tsx @@ -0,0 +1,13 @@ +import { SubPageLayout, SubPageLayoutHeader } from '@/components/ui/page-content-layout'; + +export function TargetAlertsRulesPage() { + return ( + + +

Alert rules list coming soon.

+
+ ); +} diff --git a/packages/web/app/src/pages/target-alerts.tsx b/packages/web/app/src/pages/target-alerts.tsx new file mode 100644 index 0000000000..9c363dff6c --- /dev/null +++ b/packages/web/app/src/pages/target-alerts.tsx @@ -0,0 +1,57 @@ +import { Link, Outlet } from '@tanstack/react-router'; +import { Button } from '@/components/ui/button'; +import { Meta } from '@/components/ui/meta'; +import { NavLayout, PageLayout, PageLayoutContent } from '@/components/ui/page-content-layout'; + +const navItems = [ + { label: 'Alert activity', segment: 'activity' }, + { label: 'Alert rules', segment: 'rules' }, + { label: 'Create a new alert', segment: 'create' }, +] as const; + +export function TargetAlertsPage(props: { + organizationSlug: string; + projectSlug: string; + targetSlug: string; +}) { + const params = { + organizationSlug: props.organizationSlug, + projectSlug: props.projectSlug, + targetSlug: props.targetSlug, + }; + + return ( + <> + + + + {navItems.map(item => ( + + ))} + + + + + + + ); +} diff --git a/packages/web/app/src/router.tsx b/packages/web/app/src/router.tsx index bc230a7652..cd34325e90 100644 --- a/packages/web/app/src/router.tsx +++ b/packages/web/app/src/router.tsx @@ -87,6 +87,11 @@ import { TargetLaboratoryPage as TargetLaboratoryPageNew } from './pages/target- import { ProposalTab, TargetProposalsSinglePage } from './pages/target-proposal'; import { TargetProposalsPage } from './pages/target-proposals'; import { TargetProposalsNewPage } from './pages/target-proposals-new'; +import { TargetAlertsPage } from './pages/target-alerts'; +import { TargetAlertsActivityPage } from './pages/target-alerts-activity'; +import { TargetAlertsCreatePage } from './pages/target-alerts-create'; +import { TargetAlertsDetailPage } from './pages/target-alerts-detail'; +import { TargetAlertsRulesPage } from './pages/target-alerts-rules'; import { TargetSettingsPage, TargetSettingsPageEnum } from './pages/target-settings'; import { TargetTracePage } from './pages/target-trace'; import { @@ -641,6 +646,79 @@ const targetSettingsRoute = createRoute({ }, }); +// --- Alerts (nested routes with Outlet) --- + +const targetAlertsRoute = createRoute({ + getParentRoute: () => targetRoute, + path: 'alerts', + component: function TargetAlertsRoute() { + const { organizationSlug, projectSlug, targetSlug } = targetAlertsRoute.useParams(); + return ( + + + + ); + }, +}); + +const targetAlertsIndexRoute = createRoute({ + getParentRoute: () => targetAlertsRoute, + path: '/', + component: function TargetAlertsIndexRoute() { + const params = targetAlertsIndexRoute.useParams(); + return ( + + ); + }, +}); + +const targetAlertsRulesRoute = createRoute({ + getParentRoute: () => targetAlertsRoute, + path: 'rules', + component: TargetAlertsRulesPage, +}); + +const targetAlertsActivityRoute = createRoute({ + getParentRoute: () => targetAlertsRoute, + path: 'activity', + component: TargetAlertsActivityPage, +}); + +const targetAlertsCreateRoute = createRoute({ + getParentRoute: () => targetAlertsRoute, + path: 'create', + component: TargetAlertsCreatePage, +}); + +const targetAlertsDetailRoute = createRoute({ + getParentRoute: () => targetAlertsRoute, + path: '$ruleId', + component: function TargetAlertsDetailRoute() { + const { organizationSlug, projectSlug, targetSlug, ruleId } = + targetAlertsDetailRoute.useParams(); + return ( + + ); + }, +}); + const targetLaboratoryRoute = createRoute({ getParentRoute: () => targetRoute, path: 'laboratory', @@ -1178,6 +1256,13 @@ const routeTree = root.addChildren([ targetAppVersionRoute, targetAppsRoute, targetProposalsRoute.addChildren([targetProposalsNewRoute, targetProposalsSingleRoute]), + targetAlertsRoute.addChildren([ + targetAlertsIndexRoute, + targetAlertsRulesRoute, + targetAlertsActivityRoute, + targetAlertsCreateRoute, + targetAlertsDetailRoute, + ]), ]), ]), ]); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index faf4fc8024..9b07d34b02 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -290,7 +290,7 @@ importers: devDependencies: '@graphql-hive/gateway': specifier: ^2.1.19 - version: 2.1.19(@types/ioredis-mock@8.2.5)(@types/node@24.12.2)(encoding@0.1.13)(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0)(prom-client@15.1.3) + version: 2.1.19(@types/ioredis-mock@8.2.5)(@types/node@24.12.2)(encoding@0.1.13)(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0)(prom-client@15.1.3) '@types/js-yaml': specifier: 4.0.9 version: 4.0.9 @@ -864,7 +864,7 @@ importers: version: 0.52.2 monaco-graphql: specifier: ^1.7.3 - version: 1.7.3(graphql@16.12.0)(monaco-editor@0.52.2)(prettier@3.4.2) + version: 1.7.3(graphql@16.12.0)(monaco-editor@0.52.2)(prettier@3.8.1) monacopilot: specifier: ^1.2.12 version: 1.2.12(monaco-editor@0.52.2) @@ -954,7 +954,7 @@ importers: version: 0.16.6(typescript@5.7.3) graphql-yoga: specifier: 5.13.3 - version: 5.13.3(graphql@16.9.0) + version: 5.13.3(graphql@16.12.0) ioredis: specifier: ^5.0.0 version: 5.8.2 @@ -973,7 +973,7 @@ importers: version: link:../laboratory graphql-yoga: specifier: 5.13.3 - version: 5.13.3(graphql@16.9.0) + version: 5.13.3(graphql@16.12.0) publishDirectory: dist packages/libraries/yoga: @@ -1069,7 +1069,7 @@ importers: version: 6.2.0 pg-promise: specifier: 11.10.2 - version: 11.10.2(pg-query-stream@4.14.0(pg@8.13.1)) + version: 11.10.2(pg-query-stream@4.14.0(pg@8.20.0)) tslib: specifier: 2.8.1 version: 2.8.1 @@ -1452,7 +1452,7 @@ importers: devDependencies: '@graphql-eslint/eslint-plugin': specifier: 3.20.1 - version: 3.20.1(patch_hash=09fda5b278e2d5231c4881c9d877a8b38b813c049a8e6651f7dfb9df1da7f170)(@babel/core@7.28.5)(@types/node@24.12.2)(cosmiconfig-toml-loader@1.0.0)(encoding@0.1.13)(graphql@16.9.0) + version: 3.20.1(patch_hash=09fda5b278e2d5231c4881c9d877a8b38b813c049a8e6651f7dfb9df1da7f170)(@babel/core@7.28.5)(@types/node@25.5.0)(cosmiconfig-toml-loader@1.0.0)(encoding@0.1.13)(graphql@16.9.0) '@hive/service-common': specifier: workspace:* version: link:../service-common @@ -1711,7 +1711,7 @@ importers: version: 1.0.9(pino@10.3.0) '@graphql-hive/plugin-opentelemetry': specifier: 1.3.0 - version: 1.3.0(encoding@0.1.13)(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0)(ws@8.18.0) + version: 1.3.0(encoding@0.1.13)(graphql@16.12.0)(pino@10.3.0)(ws@8.18.0) '@opentelemetry/api': specifier: 1.9.0 version: 1.9.0 @@ -1813,7 +1813,7 @@ importers: version: 16.9.0 pg-promise: specifier: 11.10.2 - version: 11.10.2(pg-query-stream@4.14.0(pg@8.13.1)) + version: 11.10.2(pg-query-stream@4.14.0(pg@8.20.0)) tslib: specifier: 2.8.1 version: 2.8.1 @@ -2398,7 +2398,7 @@ importers: version: 0.52.2 monaco-graphql: specifier: ^1.7.2 - version: 1.7.3(graphql@16.9.0)(monaco-editor@0.52.2)(prettier@3.4.2) + version: 1.7.3(graphql@16.9.0)(monaco-editor@0.52.2)(prettier@3.8.1) monaco-themes: specifier: 0.4.4 version: 0.4.4 @@ -19401,6 +19401,14 @@ snapshots: '@apollo/utils.logger': 2.0.0 graphql: 16.9.0 + '@apollo/server-gateway-interface@2.0.0(graphql@16.12.0)': + dependencies: + '@apollo/usage-reporting-protobuf': 4.1.1 + '@apollo/utils.fetcher': 3.1.0 + '@apollo/utils.keyvaluecache': 4.0.0 + '@apollo/utils.logger': 3.0.0 + graphql: 16.12.0 + '@apollo/server-gateway-interface@2.0.0(graphql@16.9.0)': dependencies: '@apollo/usage-reporting-protobuf': 4.1.1 @@ -19461,6 +19469,10 @@ snapshots: '@apollo/utils.isnodelike': 3.0.0 sha.js: 2.4.11 + '@apollo/utils.dropunuseddefinitions@2.0.1(graphql@16.12.0)': + dependencies: + graphql: 16.12.0 + '@apollo/utils.dropunuseddefinitions@2.0.1(graphql@16.9.0)': dependencies: graphql: 16.9.0 @@ -19494,23 +19506,50 @@ snapshots: '@apollo/utils.logger@3.0.0': {} + '@apollo/utils.printwithreducedwhitespace@2.0.1(graphql@16.12.0)': + dependencies: + graphql: 16.12.0 + '@apollo/utils.printwithreducedwhitespace@2.0.1(graphql@16.9.0)': dependencies: graphql: 16.9.0 + '@apollo/utils.removealiases@2.0.1(graphql@16.12.0)': + dependencies: + graphql: 16.12.0 + '@apollo/utils.removealiases@2.0.1(graphql@16.9.0)': dependencies: graphql: 16.9.0 + '@apollo/utils.sortast@2.0.1(graphql@16.12.0)': + dependencies: + graphql: 16.12.0 + lodash.sortby: 4.7.0 + '@apollo/utils.sortast@2.0.1(graphql@16.9.0)': dependencies: graphql: 16.9.0 lodash.sortby: 4.7.0 + '@apollo/utils.stripsensitiveliterals@2.0.1(graphql@16.12.0)': + dependencies: + graphql: 16.12.0 + '@apollo/utils.stripsensitiveliterals@2.0.1(graphql@16.9.0)': dependencies: graphql: 16.9.0 + '@apollo/utils.usagereporting@2.1.0(graphql@16.12.0)': + dependencies: + '@apollo/usage-reporting-protobuf': 4.1.1 + '@apollo/utils.dropunuseddefinitions': 2.0.1(graphql@16.12.0) + '@apollo/utils.printwithreducedwhitespace': 2.0.1(graphql@16.12.0) + '@apollo/utils.removealiases': 2.0.1(graphql@16.12.0) + '@apollo/utils.sortast': 2.0.1(graphql@16.12.0) + '@apollo/utils.stripsensitiveliterals': 2.0.1(graphql@16.12.0) + graphql: 16.12.0 + '@apollo/utils.usagereporting@2.1.0(graphql@16.9.0)': dependencies: '@apollo/usage-reporting-protobuf': 4.1.1 @@ -21581,12 +21620,25 @@ snapshots: '@whatwg-node/promise-helpers': 1.3.2 tslib: 2.8.1 + '@envelop/disable-introspection@9.0.0(@envelop/core@5.5.1)(graphql@16.12.0)': + dependencies: + '@envelop/core': 5.5.1 + graphql: 16.12.0 + tslib: 2.8.1 + '@envelop/disable-introspection@9.0.0(@envelop/core@5.5.1)(graphql@16.9.0)': dependencies: '@envelop/core': 5.5.1 graphql: 16.9.0 tslib: 2.8.1 + '@envelop/extended-validation@7.0.0(@envelop/core@5.5.1)(graphql@16.12.0)': + dependencies: + '@envelop/core': 5.5.1 + '@graphql-tools/utils': 10.9.1(graphql@16.12.0) + graphql: 16.12.0 + tslib: 2.8.1 + '@envelop/extended-validation@7.0.0(@envelop/core@5.5.1)(graphql@16.9.0)': dependencies: '@envelop/core': 5.5.1 @@ -21594,6 +21646,16 @@ snapshots: graphql: 16.9.0 tslib: 2.8.1 + '@envelop/generic-auth@11.0.0(@envelop/core@5.5.1)(graphql@16.12.0)': + dependencies: + '@envelop/core': 5.5.1 + '@envelop/extended-validation': 7.0.0(@envelop/core@5.5.1)(graphql@16.12.0) + '@graphql-tools/executor': 1.5.0(graphql@16.12.0) + '@graphql-tools/utils': 10.9.1(graphql@16.12.0) + '@whatwg-node/promise-helpers': 1.3.2 + graphql: 16.12.0 + tslib: 2.8.1 + '@envelop/generic-auth@11.0.0(@envelop/core@5.5.1)(graphql@16.9.0)': dependencies: '@envelop/core': 5.5.1 @@ -21629,6 +21691,12 @@ snapshots: '@envelop/core': 5.5.1 graphql: 16.9.0 + '@envelop/on-resolve@7.0.0(@envelop/core@5.5.1)(graphql@16.12.0)': + dependencies: + '@envelop/core': 5.5.1 + '@whatwg-node/promise-helpers': 1.3.2 + graphql: 16.12.0 + '@envelop/on-resolve@7.0.0(@envelop/core@5.5.1)(graphql@16.9.0)': dependencies: '@envelop/core': 5.5.1 @@ -21644,22 +21712,22 @@ snapshots: graphql: 16.9.0 tslib: 2.8.1 - '@envelop/prometheus@14.0.0(@envelop/core@5.5.1)(graphql@16.9.0)(prom-client@15.1.3)': + '@envelop/prometheus@14.0.0(@envelop/core@5.5.1)(graphql@16.12.0)(prom-client@15.1.3)': dependencies: '@envelop/core': 5.5.1 - '@envelop/on-resolve': 7.0.0(@envelop/core@5.5.1)(graphql@16.9.0) - graphql: 16.9.0 + '@envelop/on-resolve': 7.0.0(@envelop/core@5.5.1)(graphql@16.12.0) + graphql: 16.12.0 prom-client: 15.1.3 tslib: 2.8.1 - '@envelop/rate-limiter@9.0.0(@envelop/core@5.5.1)(graphql@16.9.0)': + '@envelop/rate-limiter@9.0.0(@envelop/core@5.5.1)(graphql@16.12.0)': dependencies: '@envelop/core': 5.5.1 - '@envelop/on-resolve': 7.0.0(@envelop/core@5.5.1)(graphql@16.9.0) - '@graphql-tools/utils': 10.9.1(graphql@16.9.0) + '@envelop/on-resolve': 7.0.0(@envelop/core@5.5.1)(graphql@16.12.0) + '@graphql-tools/utils': 10.9.1(graphql@16.12.0) '@types/picomatch': 4.0.2 '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.9.0 + graphql: 16.12.0 lodash.get: 4.4.2 ms: 2.1.3 picomatch: 4.0.4 @@ -21675,6 +21743,17 @@ snapshots: lru-cache: 10.2.0 tslib: 2.8.1 + '@envelop/response-cache@7.1.3(@envelop/core@5.5.1)(graphql@16.12.0)': + dependencies: + '@envelop/core': 5.5.1 + '@graphql-tools/utils': 10.9.1(graphql@16.12.0) + '@whatwg-node/fetch': 0.10.13 + '@whatwg-node/promise-helpers': 1.3.2 + fast-json-stable-stringify: 2.1.0 + graphql: 16.12.0 + lru-cache: 10.2.0 + tslib: 2.8.1 + '@envelop/response-cache@7.1.3(@envelop/core@5.5.1)(graphql@16.9.0)': dependencies: '@envelop/core': 5.5.1 @@ -21686,6 +21765,17 @@ snapshots: lru-cache: 10.2.0 tslib: 2.8.1 + '@envelop/response-cache@9.0.0(@envelop/core@5.5.1)(graphql@16.12.0)': + dependencies: + '@envelop/core': 5.5.1 + '@graphql-tools/utils': 10.9.1(graphql@16.12.0) + '@whatwg-node/fetch': 0.10.13 + '@whatwg-node/promise-helpers': 1.3.2 + fast-json-stable-stringify: 2.1.0 + graphql: 16.12.0 + lru-cache: 11.0.2 + tslib: 2.8.1 + '@envelop/response-cache@9.0.0(@envelop/core@5.5.1)(graphql@16.9.0)': dependencies: '@envelop/core': 5.5.1 @@ -22356,6 +22446,46 @@ snapshots: - supports-color - utf-8-validate + '@graphql-eslint/eslint-plugin@3.20.1(patch_hash=09fda5b278e2d5231c4881c9d877a8b38b813c049a8e6651f7dfb9df1da7f170)(@babel/core@7.28.5)(@types/node@25.5.0)(cosmiconfig-toml-loader@1.0.0)(encoding@0.1.13)(graphql@16.9.0)': + dependencies: + '@babel/code-frame': 7.29.0 + '@graphql-tools/code-file-loader': 7.3.23(@babel/core@7.28.5)(graphql@16.9.0) + '@graphql-tools/graphql-tag-pluck': 7.5.2(@babel/core@7.28.5)(graphql@16.9.0) + '@graphql-tools/utils': 9.2.1(graphql@16.9.0) + chalk: 4.1.2 + debug: 4.4.3(supports-color@8.1.1) + fast-glob: 3.3.3 + graphql: 16.9.0 + graphql-config: 4.5.0(@types/node@25.5.0)(cosmiconfig-toml-loader@1.0.0)(encoding@0.1.13)(graphql@16.9.0) + graphql-depth-limit: 1.1.0(graphql@16.9.0) + lodash.lowercase: 4.3.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@babel/core' + - '@types/node' + - bufferutil + - cosmiconfig-toml-loader + - encoding + - supports-color + - utf-8-validate + + '@graphql-hive/core@0.18.0(graphql@16.12.0)(pino@10.3.0)': + dependencies: + '@graphql-hive/logger': 1.0.9(pino@10.3.0) + '@graphql-hive/signal': 2.0.0 + '@graphql-tools/utils': 10.9.1(graphql@16.12.0) + '@whatwg-node/fetch': 0.10.13 + async-retry: 1.3.3 + events: 3.3.0 + graphql: 16.12.0 + js-md5: 0.8.3 + lodash.sortby: 4.7.0 + tiny-lru: 8.0.2 + transitivePeerDependencies: + - '@logtape/logtape' + - pino + - winston + '@graphql-hive/core@0.18.0(graphql@16.9.0)(pino@10.3.0)': dependencies: '@graphql-hive/logger': 1.0.9(pino@10.3.0) @@ -22373,6 +22503,106 @@ snapshots: - pino - winston + '@graphql-hive/gateway-runtime@2.5.0(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0)(ws@8.18.0)': + dependencies: + '@envelop/core': 5.5.1 + '@envelop/disable-introspection': 9.0.0(@envelop/core@5.5.1)(graphql@16.12.0) + '@envelop/generic-auth': 11.0.0(@envelop/core@5.5.1)(graphql@16.12.0) + '@envelop/instrumentation': 1.0.0 + '@graphql-hive/core': 0.18.0(graphql@16.12.0)(pino@10.3.0) + '@graphql-hive/logger': 1.0.9(pino@10.3.0) + '@graphql-hive/pubsub': 2.1.1(ioredis@5.8.2) + '@graphql-hive/signal': 2.0.0 + '@graphql-hive/yoga': 0.46.0(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0)(pino@10.3.0) + '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.12.0) + '@graphql-mesh/fusion-runtime': 1.6.2(@types/node@25.5.0)(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0) + '@graphql-mesh/hmac-upstream-signature': 2.0.8(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-mesh/plugin-response-cache': 0.104.18(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-mesh/transport-common': 1.0.12(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0) + '@graphql-mesh/types': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-mesh/utils': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-tools/batch-delegate': 10.0.8(graphql@16.12.0) + '@graphql-tools/delegate': 12.0.2(graphql@16.12.0) + '@graphql-tools/executor-common': 1.0.5(graphql@16.12.0) + '@graphql-tools/executor-http': 3.0.7(@types/node@25.5.0)(graphql@16.12.0) + '@graphql-tools/federation': 4.2.6(@types/node@25.5.0)(graphql@16.12.0) + '@graphql-tools/stitch': 10.1.6(graphql@16.12.0) + '@graphql-tools/utils': 10.11.0(graphql@16.12.0) + '@graphql-tools/wrap': 11.1.2(graphql@16.12.0) + '@graphql-yoga/plugin-apollo-usage-report': 0.12.1(@envelop/core@5.5.1)(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0) + '@graphql-yoga/plugin-csrf-prevention': 3.16.2(graphql-yoga@5.17.1(graphql@16.12.0)) + '@graphql-yoga/plugin-defer-stream': 3.16.2(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0) + '@graphql-yoga/plugin-persisted-operations': 3.16.2(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0) + '@types/node': 25.5.0 + '@whatwg-node/disposablestack': 0.0.6 + '@whatwg-node/promise-helpers': 1.3.2 + '@whatwg-node/server': 0.10.17 + '@whatwg-node/server-plugin-cookies': 1.0.5 + graphql: 16.12.0 + graphql-ws: 6.0.6(graphql@16.12.0)(ws@8.18.0) + graphql-yoga: 5.17.1(graphql@16.12.0) + tslib: 2.8.1 + transitivePeerDependencies: + - '@fastify/websocket' + - '@logtape/logtape' + - '@nats-io/nats-core' + - crossws + - ioredis + - pino + - uWebSockets.js + - winston + - ws + + '@graphql-hive/gateway-runtime@2.5.0(graphql@16.12.0)(pino@10.3.0)(ws@8.18.0)': + dependencies: + '@envelop/core': 5.5.1 + '@envelop/disable-introspection': 9.0.0(@envelop/core@5.5.1)(graphql@16.12.0) + '@envelop/generic-auth': 11.0.0(@envelop/core@5.5.1)(graphql@16.12.0) + '@envelop/instrumentation': 1.0.0 + '@graphql-hive/core': 0.18.0(graphql@16.12.0)(pino@10.3.0) + '@graphql-hive/logger': 1.0.9(pino@10.3.0) + '@graphql-hive/pubsub': 2.1.1(ioredis@5.8.2) + '@graphql-hive/signal': 2.0.0 + '@graphql-hive/yoga': 0.46.0(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0)(pino@10.3.0) + '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.12.0) + '@graphql-mesh/fusion-runtime': 1.6.2(@types/node@25.5.0)(graphql@16.12.0)(pino@10.3.0) + '@graphql-mesh/hmac-upstream-signature': 2.0.8(graphql@16.12.0) + '@graphql-mesh/plugin-response-cache': 0.104.18(graphql@16.12.0) + '@graphql-mesh/transport-common': 1.0.12(graphql@16.12.0)(pino@10.3.0) + '@graphql-mesh/types': 0.104.16(graphql@16.12.0) + '@graphql-mesh/utils': 0.104.16(graphql@16.12.0) + '@graphql-tools/batch-delegate': 10.0.8(graphql@16.12.0) + '@graphql-tools/delegate': 12.0.2(graphql@16.12.0) + '@graphql-tools/executor-common': 1.0.5(graphql@16.12.0) + '@graphql-tools/executor-http': 3.0.7(@types/node@25.5.0)(graphql@16.12.0) + '@graphql-tools/federation': 4.2.6(@types/node@25.5.0)(graphql@16.12.0) + '@graphql-tools/stitch': 10.1.6(graphql@16.12.0) + '@graphql-tools/utils': 10.11.0(graphql@16.12.0) + '@graphql-tools/wrap': 11.1.2(graphql@16.12.0) + '@graphql-yoga/plugin-apollo-usage-report': 0.12.1(@envelop/core@5.5.1)(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0) + '@graphql-yoga/plugin-csrf-prevention': 3.16.2(graphql-yoga@5.17.1(graphql@16.12.0)) + '@graphql-yoga/plugin-defer-stream': 3.16.2(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0) + '@graphql-yoga/plugin-persisted-operations': 3.16.2(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0) + '@types/node': 25.5.0 + '@whatwg-node/disposablestack': 0.0.6 + '@whatwg-node/promise-helpers': 1.3.2 + '@whatwg-node/server': 0.10.17 + '@whatwg-node/server-plugin-cookies': 1.0.5 + graphql: 16.12.0 + graphql-ws: 6.0.6(graphql@16.12.0)(ws@8.18.0) + graphql-yoga: 5.17.1(graphql@16.12.0) + tslib: 2.8.1 + transitivePeerDependencies: + - '@fastify/websocket' + - '@logtape/logtape' + - '@nats-io/nats-core' + - crossws + - ioredis + - pino + - uWebSockets.js + - winston + - ws + '@graphql-hive/gateway-runtime@2.5.0(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0)(ws@8.18.0)': dependencies: '@envelop/core': 5.5.1 @@ -22423,41 +22653,41 @@ snapshots: - winston - ws - '@graphql-hive/gateway@2.1.19(@types/ioredis-mock@8.2.5)(@types/node@24.12.2)(encoding@0.1.13)(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0)(prom-client@15.1.3)': + '@graphql-hive/gateway@2.1.19(@types/ioredis-mock@8.2.5)(@types/node@24.12.2)(encoding@0.1.13)(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0)(prom-client@15.1.3)': dependencies: '@commander-js/extra-typings': 14.0.0(commander@14.0.2) '@envelop/core': 5.5.1 '@escape.tech/graphql-armor-block-field-suggestions': 3.0.1 '@escape.tech/graphql-armor-max-depth': 2.4.2 '@escape.tech/graphql-armor-max-tokens': 2.5.1 - '@graphql-hive/gateway-runtime': 2.5.0(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0)(ws@8.18.0) + '@graphql-hive/gateway-runtime': 2.5.0(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0)(ws@8.18.0) '@graphql-hive/importer': 2.0.0 '@graphql-hive/logger': 1.0.9(pino@10.3.0) - '@graphql-hive/plugin-aws-sigv4': 2.0.17(@types/node@24.12.2)(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0) - '@graphql-hive/plugin-opentelemetry': 1.3.0(encoding@0.1.13)(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0)(ws@8.18.0) + '@graphql-hive/plugin-aws-sigv4': 2.0.17(@types/node@24.12.2)(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0) + '@graphql-hive/plugin-opentelemetry': 1.3.0(encoding@0.1.13)(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0)(ws@8.18.0) '@graphql-hive/pubsub': 2.1.1(ioredis@5.8.2) - '@graphql-mesh/cache-cfw-kv': 0.105.16(graphql@16.9.0)(ioredis@5.8.2) - '@graphql-mesh/cache-localforage': 0.105.17(graphql@16.9.0)(ioredis@5.8.2) - '@graphql-mesh/cache-redis': 0.105.2(@types/ioredis-mock@8.2.5)(graphql@16.9.0) - '@graphql-mesh/cache-upstash-redis': 0.1.16(graphql@16.9.0)(ioredis@5.8.2) - '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.9.0) - '@graphql-mesh/hmac-upstream-signature': 2.0.8(graphql@16.9.0)(ioredis@5.8.2) - '@graphql-mesh/plugin-http-cache': 0.105.17(graphql@16.9.0)(ioredis@5.8.2) - '@graphql-mesh/plugin-jit': 0.2.16(graphql@16.9.0)(ioredis@5.8.2) - '@graphql-mesh/plugin-jwt-auth': 2.0.9(graphql-yoga@5.17.1(graphql@16.9.0))(graphql@16.9.0)(ioredis@5.8.2) - '@graphql-mesh/plugin-prometheus': 2.1.5(@envelop/core@5.5.1)(graphql-yoga@5.17.1(graphql@16.9.0))(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0)(prom-client@15.1.3)(ws@8.18.0) - '@graphql-mesh/plugin-rate-limit': 0.105.5(@envelop/core@5.5.1)(graphql@16.9.0)(ioredis@5.8.2) - '@graphql-mesh/plugin-snapshot': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) - '@graphql-mesh/transport-http': 1.0.12(@types/node@24.12.2)(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0) - '@graphql-mesh/transport-http-callback': 1.0.12(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0) - '@graphql-mesh/transport-ws': 2.0.12(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0) - '@graphql-mesh/types': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) - '@graphql-mesh/utils': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) - '@graphql-tools/code-file-loader': 8.1.26(graphql@16.9.0) - '@graphql-tools/graphql-file-loader': 8.1.7(graphql@16.9.0) - '@graphql-tools/load': 8.1.6(graphql@16.9.0) - '@graphql-tools/utils': 10.11.0(graphql@16.9.0) - '@graphql-yoga/render-graphiql': 5.16.2(graphql-yoga@5.17.1(graphql@16.9.0)) + '@graphql-mesh/cache-cfw-kv': 0.105.16(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-mesh/cache-localforage': 0.105.17(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-mesh/cache-redis': 0.105.2(@types/ioredis-mock@8.2.5)(graphql@16.12.0) + '@graphql-mesh/cache-upstash-redis': 0.1.16(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.12.0) + '@graphql-mesh/hmac-upstream-signature': 2.0.8(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-mesh/plugin-http-cache': 0.105.17(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-mesh/plugin-jit': 0.2.16(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-mesh/plugin-jwt-auth': 2.0.9(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-mesh/plugin-prometheus': 2.1.5(@envelop/core@5.5.1)(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0)(prom-client@15.1.3)(ws@8.18.0) + '@graphql-mesh/plugin-rate-limit': 0.105.5(@envelop/core@5.5.1)(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-mesh/plugin-snapshot': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-mesh/transport-http': 1.0.12(@types/node@24.12.2)(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0) + '@graphql-mesh/transport-http-callback': 1.0.12(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0) + '@graphql-mesh/transport-ws': 2.0.12(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0) + '@graphql-mesh/types': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-mesh/utils': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-tools/code-file-loader': 8.1.26(graphql@16.12.0) + '@graphql-tools/graphql-file-loader': 8.1.7(graphql@16.12.0) + '@graphql-tools/load': 8.1.6(graphql@16.12.0) + '@graphql-tools/utils': 10.11.0(graphql@16.12.0) + '@graphql-yoga/render-graphiql': 5.16.2(graphql-yoga@5.17.1(graphql@16.12.0)) '@opentelemetry/api': 1.9.0 '@opentelemetry/api-logs': 0.208.0 '@opentelemetry/context-async-hooks': 2.2.0(@opentelemetry/api@1.9.0) @@ -22473,9 +22703,9 @@ snapshots: '@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0) commander: 14.0.2 dotenv: 17.2.3 - graphql: 16.9.0 - graphql-ws: 6.0.6(graphql@16.9.0)(ws@8.18.0) - graphql-yoga: 5.17.1(graphql@16.9.0) + graphql: 16.12.0 + graphql-ws: 6.0.6(graphql@16.12.0)(ws@8.18.0) + graphql-yoga: 5.17.1(graphql@16.12.0) tslib: 2.8.1 ws: 8.18.0 transitivePeerDependencies: @@ -22502,13 +22732,13 @@ snapshots: optionalDependencies: pino: 10.3.0 - '@graphql-hive/plugin-aws-sigv4@2.0.17(@types/node@24.12.2)(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0)': + '@graphql-hive/plugin-aws-sigv4@2.0.17(@types/node@24.12.2)(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0)': dependencies: '@aws-sdk/client-sts': 3.939.0 - '@graphql-mesh/fusion-runtime': 1.6.2(@types/node@24.12.2)(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0) + '@graphql-mesh/fusion-runtime': 1.6.2(@types/node@24.12.2)(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0) '@whatwg-node/promise-helpers': 1.3.2 aws4: 1.13.2 - graphql: 16.9.0 + graphql: 16.12.0 tslib: 2.8.1 transitivePeerDependencies: - '@logtape/logtape' @@ -22519,6 +22749,84 @@ snapshots: - pino - winston + '@graphql-hive/plugin-opentelemetry@1.3.0(encoding@0.1.13)(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0)(ws@8.18.0)': + dependencies: + '@graphql-hive/core': 0.18.0(graphql@16.12.0)(pino@10.3.0) + '@graphql-hive/gateway-runtime': 2.5.0(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0)(ws@8.18.0) + '@graphql-hive/logger': 1.0.9(pino@10.3.0) + '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.12.0) + '@graphql-mesh/transport-common': 1.0.12(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0) + '@graphql-mesh/types': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-mesh/utils': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-tools/utils': 10.11.0(graphql@16.12.0) + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.208.0 + '@opentelemetry/auto-instrumentations-node': 0.67.2(@opentelemetry/api@1.9.0)(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13) + '@opentelemetry/context-async-hooks': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-grpc': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-http': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-node': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.38.0 + '@whatwg-node/promise-helpers': 1.3.2 + graphql: 16.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@fastify/websocket' + - '@logtape/logtape' + - '@nats-io/nats-core' + - crossws + - encoding + - ioredis + - pino + - supports-color + - uWebSockets.js + - winston + - ws + + '@graphql-hive/plugin-opentelemetry@1.3.0(encoding@0.1.13)(graphql@16.12.0)(pino@10.3.0)(ws@8.18.0)': + dependencies: + '@graphql-hive/core': 0.18.0(graphql@16.12.0)(pino@10.3.0) + '@graphql-hive/gateway-runtime': 2.5.0(graphql@16.12.0)(pino@10.3.0)(ws@8.18.0) + '@graphql-hive/logger': 1.0.9(pino@10.3.0) + '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.12.0) + '@graphql-mesh/transport-common': 1.0.12(graphql@16.12.0)(pino@10.3.0) + '@graphql-mesh/types': 0.104.16(graphql@16.12.0) + '@graphql-mesh/utils': 0.104.16(graphql@16.12.0) + '@graphql-tools/utils': 10.11.0(graphql@16.12.0) + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.208.0 + '@opentelemetry/auto-instrumentations-node': 0.67.2(@opentelemetry/api@1.9.0)(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13) + '@opentelemetry/context-async-hooks': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-grpc': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-http': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-node': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.38.0 + '@whatwg-node/promise-helpers': 1.3.2 + graphql: 16.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@fastify/websocket' + - '@logtape/logtape' + - '@nats-io/nats-core' + - crossws + - encoding + - ioredis + - pino + - supports-color + - uWebSockets.js + - winston + - ws + '@graphql-hive/plugin-opentelemetry@1.3.0(encoding@0.1.13)(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0)(ws@8.18.0)': dependencies: '@graphql-hive/core': 0.18.0(graphql@16.9.0)(pino@10.3.0) @@ -22570,6 +22878,18 @@ snapshots: '@graphql-hive/signal@2.0.0': {} + '@graphql-hive/yoga@0.46.0(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0)(pino@10.3.0)': + dependencies: + '@graphql-hive/core': 0.18.0(graphql@16.12.0)(pino@10.3.0) + '@graphql-hive/logger': 1.0.9(pino@10.3.0) + '@graphql-yoga/plugin-persisted-operations': 3.16.2(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0) + graphql: 16.12.0 + graphql-yoga: 5.17.1(graphql@16.12.0) + transitivePeerDependencies: + - '@logtape/logtape' + - pino + - winston + '@graphql-hive/yoga@0.46.0(graphql-yoga@5.17.1(graphql@16.9.0))(graphql@16.9.0)(pino@10.3.0)': dependencies: '@graphql-hive/core': 0.18.0(graphql@16.9.0)(pino@10.3.0) @@ -22813,48 +23133,48 @@ snapshots: - '@graphql-inspector/loaders' - yargs - '@graphql-mesh/cache-cfw-kv@0.105.16(graphql@16.9.0)(ioredis@5.8.2)': + '@graphql-mesh/cache-cfw-kv@0.105.16(graphql@16.12.0)(ioredis@5.8.2)': dependencies: - '@graphql-mesh/types': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) - '@graphql-mesh/utils': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) + '@graphql-mesh/types': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-mesh/utils': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.9.0 + graphql: 16.12.0 tslib: 2.8.1 transitivePeerDependencies: - '@nats-io/nats-core' - ioredis - '@graphql-mesh/cache-inmemory-lru@0.8.17(graphql@16.9.0)(ioredis@5.8.2)': + '@graphql-mesh/cache-inmemory-lru@0.8.17(graphql@16.12.0)(ioredis@5.8.2)': dependencies: - '@graphql-mesh/types': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) - '@graphql-mesh/utils': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) + '@graphql-mesh/types': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-mesh/utils': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) '@whatwg-node/disposablestack': 0.0.6 - graphql: 16.9.0 + graphql: 16.12.0 tslib: 2.8.1 transitivePeerDependencies: - '@nats-io/nats-core' - ioredis - '@graphql-mesh/cache-localforage@0.105.17(graphql@16.9.0)(ioredis@5.8.2)': + '@graphql-mesh/cache-localforage@0.105.17(graphql@16.12.0)(ioredis@5.8.2)': dependencies: - '@graphql-mesh/cache-inmemory-lru': 0.8.17(graphql@16.9.0)(ioredis@5.8.2) - '@graphql-mesh/types': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) - '@graphql-mesh/utils': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) - graphql: 16.9.0 + '@graphql-mesh/cache-inmemory-lru': 0.8.17(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-mesh/types': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-mesh/utils': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) + graphql: 16.12.0 localforage: 1.10.0 tslib: 2.8.1 transitivePeerDependencies: - '@nats-io/nats-core' - ioredis - '@graphql-mesh/cache-redis@0.105.2(@types/ioredis-mock@8.2.5)(graphql@16.9.0)': + '@graphql-mesh/cache-redis@0.105.2(@types/ioredis-mock@8.2.5)(graphql@16.12.0)': dependencies: - '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.9.0) - '@graphql-mesh/string-interpolation': 0.5.9(graphql@16.9.0) - '@graphql-mesh/types': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) + '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.12.0) + '@graphql-mesh/string-interpolation': 0.5.9(graphql@16.12.0) + '@graphql-mesh/types': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) '@opentelemetry/api': 1.9.0 '@whatwg-node/disposablestack': 0.0.6 - graphql: 16.9.0 + graphql: 16.12.0 ioredis: 5.8.2 ioredis-mock: 8.13.1(@types/ioredis-mock@8.2.5)(ioredis@5.8.2) tslib: 2.8.1 @@ -22863,46 +23183,114 @@ snapshots: - '@types/ioredis-mock' - supports-color - '@graphql-mesh/cache-upstash-redis@0.1.16(graphql@16.9.0)(ioredis@5.8.2)': + '@graphql-mesh/cache-upstash-redis@0.1.16(graphql@16.12.0)(ioredis@5.8.2)': dependencies: - '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.9.0) - '@graphql-mesh/types': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) + '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.12.0) + '@graphql-mesh/types': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) '@upstash/redis': 1.35.6 '@whatwg-node/disposablestack': 0.0.6 - graphql: 16.9.0 + graphql: 16.12.0 tslib: 2.8.1 transitivePeerDependencies: - '@nats-io/nats-core' - ioredis + '@graphql-mesh/cross-helpers@0.4.10(graphql@16.12.0)': + dependencies: + '@graphql-tools/utils': 10.9.1(graphql@16.12.0) + graphql: 16.12.0 + path-browserify: 1.0.1 + '@graphql-mesh/cross-helpers@0.4.10(graphql@16.9.0)': dependencies: '@graphql-tools/utils': 10.9.1(graphql@16.9.0) graphql: 16.9.0 path-browserify: 1.0.1 - '@graphql-mesh/fusion-runtime@1.6.2(@types/node@24.12.2)(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0)': + '@graphql-mesh/fusion-runtime@1.6.2(@types/node@24.12.2)(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0)': dependencies: '@envelop/core': 5.5.1 '@envelop/instrumentation': 1.0.0 '@graphql-hive/logger': 1.0.9(pino@10.3.0) - '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.9.0) - '@graphql-mesh/transport-common': 1.0.12(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0) - '@graphql-mesh/types': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) - '@graphql-mesh/utils': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) - '@graphql-tools/batch-execute': 10.0.4(graphql@16.9.0) - '@graphql-tools/delegate': 12.0.2(graphql@16.9.0) - '@graphql-tools/executor': 1.5.0(graphql@16.9.0) - '@graphql-tools/federation': 4.2.6(@types/node@24.12.2)(graphql@16.9.0) - '@graphql-tools/merge': 9.1.5(graphql@16.9.0) - '@graphql-tools/stitch': 10.1.6(graphql@16.9.0) - '@graphql-tools/stitching-directives': 4.0.8(graphql@16.9.0) - '@graphql-tools/utils': 10.11.0(graphql@16.9.0) - '@graphql-tools/wrap': 11.1.2(graphql@16.9.0) + '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.12.0) + '@graphql-mesh/transport-common': 1.0.12(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0) + '@graphql-mesh/types': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-mesh/utils': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-tools/batch-execute': 10.0.4(graphql@16.12.0) + '@graphql-tools/delegate': 12.0.2(graphql@16.12.0) + '@graphql-tools/executor': 1.5.0(graphql@16.12.0) + '@graphql-tools/federation': 4.2.6(@types/node@24.12.2)(graphql@16.12.0) + '@graphql-tools/merge': 9.1.5(graphql@16.12.0) + '@graphql-tools/stitch': 10.1.6(graphql@16.12.0) + '@graphql-tools/stitching-directives': 4.0.8(graphql@16.12.0) + '@graphql-tools/utils': 10.11.0(graphql@16.12.0) + '@graphql-tools/wrap': 11.1.2(graphql@16.12.0) '@whatwg-node/disposablestack': 0.0.6 '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.9.0 - graphql-yoga: 5.17.1(graphql@16.9.0) + graphql: 16.12.0 + graphql-yoga: 5.17.1(graphql@16.12.0) + tslib: 2.8.1 + transitivePeerDependencies: + - '@logtape/logtape' + - '@nats-io/nats-core' + - '@types/node' + - ioredis + - pino + - winston + + '@graphql-mesh/fusion-runtime@1.6.2(@types/node@25.5.0)(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0)': + dependencies: + '@envelop/core': 5.5.1 + '@envelop/instrumentation': 1.0.0 + '@graphql-hive/logger': 1.0.9(pino@10.3.0) + '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.12.0) + '@graphql-mesh/transport-common': 1.0.12(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0) + '@graphql-mesh/types': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-mesh/utils': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-tools/batch-execute': 10.0.4(graphql@16.12.0) + '@graphql-tools/delegate': 12.0.2(graphql@16.12.0) + '@graphql-tools/executor': 1.5.0(graphql@16.12.0) + '@graphql-tools/federation': 4.2.6(@types/node@25.5.0)(graphql@16.12.0) + '@graphql-tools/merge': 9.1.5(graphql@16.12.0) + '@graphql-tools/stitch': 10.1.6(graphql@16.12.0) + '@graphql-tools/stitching-directives': 4.0.8(graphql@16.12.0) + '@graphql-tools/utils': 10.11.0(graphql@16.12.0) + '@graphql-tools/wrap': 11.1.2(graphql@16.12.0) + '@whatwg-node/disposablestack': 0.0.6 + '@whatwg-node/promise-helpers': 1.3.2 + graphql: 16.12.0 + graphql-yoga: 5.17.1(graphql@16.12.0) + tslib: 2.8.1 + transitivePeerDependencies: + - '@logtape/logtape' + - '@nats-io/nats-core' + - '@types/node' + - ioredis + - pino + - winston + + '@graphql-mesh/fusion-runtime@1.6.2(@types/node@25.5.0)(graphql@16.12.0)(pino@10.3.0)': + dependencies: + '@envelop/core': 5.5.1 + '@envelop/instrumentation': 1.0.0 + '@graphql-hive/logger': 1.0.9(pino@10.3.0) + '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.12.0) + '@graphql-mesh/transport-common': 1.0.12(graphql@16.12.0)(pino@10.3.0) + '@graphql-mesh/types': 0.104.16(graphql@16.12.0) + '@graphql-mesh/utils': 0.104.16(graphql@16.12.0) + '@graphql-tools/batch-execute': 10.0.4(graphql@16.12.0) + '@graphql-tools/delegate': 12.0.2(graphql@16.12.0) + '@graphql-tools/executor': 1.5.0(graphql@16.12.0) + '@graphql-tools/federation': 4.2.6(@types/node@25.5.0)(graphql@16.12.0) + '@graphql-tools/merge': 9.1.5(graphql@16.12.0) + '@graphql-tools/stitch': 10.1.6(graphql@16.12.0) + '@graphql-tools/stitching-directives': 4.0.8(graphql@16.12.0) + '@graphql-tools/utils': 10.11.0(graphql@16.12.0) + '@graphql-tools/wrap': 11.1.2(graphql@16.12.0) + '@whatwg-node/disposablestack': 0.0.6 + '@whatwg-node/promise-helpers': 1.3.2 + graphql: 16.12.0 + graphql-yoga: 5.17.1(graphql@16.12.0) tslib: 2.8.1 transitivePeerDependencies: - '@logtape/logtape' @@ -22943,6 +23331,36 @@ snapshots: - pino - winston + '@graphql-mesh/hmac-upstream-signature@2.0.8(graphql@16.12.0)': + dependencies: + '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.12.0) + '@graphql-mesh/types': 0.104.16(graphql@16.12.0) + '@graphql-mesh/utils': 0.104.16(graphql@16.12.0) + '@graphql-tools/executor-common': 1.0.5(graphql@16.12.0) + '@graphql-tools/utils': 10.11.0(graphql@16.12.0) + '@whatwg-node/promise-helpers': 1.3.2 + graphql: 16.12.0 + json-stable-stringify: 1.3.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@nats-io/nats-core' + - ioredis + + '@graphql-mesh/hmac-upstream-signature@2.0.8(graphql@16.12.0)(ioredis@5.8.2)': + dependencies: + '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.12.0) + '@graphql-mesh/types': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-mesh/utils': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-tools/executor-common': 1.0.5(graphql@16.12.0) + '@graphql-tools/utils': 10.11.0(graphql@16.12.0) + '@whatwg-node/promise-helpers': 1.3.2 + graphql: 16.12.0 + json-stable-stringify: 1.3.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@nats-io/nats-core' + - ioredis + '@graphql-mesh/hmac-upstream-signature@2.0.8(graphql@16.9.0)(ioredis@5.8.2)': dependencies: '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.9.0) @@ -22958,37 +23376,37 @@ snapshots: - '@nats-io/nats-core' - ioredis - '@graphql-mesh/plugin-http-cache@0.105.17(graphql@16.9.0)(ioredis@5.8.2)': + '@graphql-mesh/plugin-http-cache@0.105.17(graphql@16.12.0)(ioredis@5.8.2)': dependencies: - '@graphql-mesh/types': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) - '@graphql-mesh/utils': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) + '@graphql-mesh/types': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-mesh/utils': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) '@whatwg-node/fetch': 0.10.13 '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.9.0 + graphql: 16.12.0 http-cache-semantics: 4.1.1 tslib: 2.8.1 transitivePeerDependencies: - '@nats-io/nats-core' - ioredis - '@graphql-mesh/plugin-jit@0.2.16(graphql@16.9.0)(ioredis@5.8.2)': + '@graphql-mesh/plugin-jit@0.2.16(graphql@16.12.0)(ioredis@5.8.2)': dependencies: '@envelop/core': 5.5.1 - '@graphql-mesh/utils': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) - '@graphql-tools/utils': 10.9.1(graphql@16.9.0) - graphql: 16.9.0 - graphql-jit: 0.8.7(graphql@16.9.0) + '@graphql-mesh/utils': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-tools/utils': 10.9.1(graphql@16.12.0) + graphql: 16.12.0 + graphql-jit: 0.8.7(graphql@16.12.0) tslib: 2.8.1 transitivePeerDependencies: - '@nats-io/nats-core' - ioredis - '@graphql-mesh/plugin-jwt-auth@2.0.9(graphql-yoga@5.17.1(graphql@16.9.0))(graphql@16.9.0)(ioredis@5.8.2)': + '@graphql-mesh/plugin-jwt-auth@2.0.9(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0)(ioredis@5.8.2)': dependencies: - '@graphql-mesh/types': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) - '@graphql-mesh/utils': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) - '@graphql-yoga/plugin-jwt': 3.10.2(graphql-yoga@5.17.1(graphql@16.9.0))(graphql@16.9.0) - graphql: 16.9.0 + '@graphql-mesh/types': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-mesh/utils': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-yoga/plugin-jwt': 3.10.2(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0) + graphql: 16.12.0 tslib: 2.8.1 transitivePeerDependencies: - '@nats-io/nats-core' @@ -22996,16 +23414,16 @@ snapshots: - ioredis - supports-color - '@graphql-mesh/plugin-prometheus@2.1.5(@envelop/core@5.5.1)(graphql-yoga@5.17.1(graphql@16.9.0))(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0)(prom-client@15.1.3)(ws@8.18.0)': + '@graphql-mesh/plugin-prometheus@2.1.5(@envelop/core@5.5.1)(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0)(prom-client@15.1.3)(ws@8.18.0)': dependencies: - '@graphql-hive/gateway-runtime': 2.5.0(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0)(ws@8.18.0) + '@graphql-hive/gateway-runtime': 2.5.0(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0)(ws@8.18.0) '@graphql-hive/logger': 1.0.9(pino@10.3.0) - '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.9.0) - '@graphql-mesh/types': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) - '@graphql-mesh/utils': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) - '@graphql-tools/utils': 10.11.0(graphql@16.9.0) - '@graphql-yoga/plugin-prometheus': 6.11.3(@envelop/core@5.5.1)(graphql-yoga@5.17.1(graphql@16.9.0))(graphql@16.9.0)(prom-client@15.1.3) - graphql: 16.9.0 + '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.12.0) + '@graphql-mesh/types': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-mesh/utils': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-tools/utils': 10.11.0(graphql@16.12.0) + '@graphql-yoga/plugin-prometheus': 6.11.3(@envelop/core@5.5.1)(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0)(prom-client@15.1.3) + graphql: 16.12.0 prom-client: 15.1.3 tslib: 2.8.1 transitivePeerDependencies: @@ -23021,22 +23439,60 @@ snapshots: - winston - ws - '@graphql-mesh/plugin-rate-limit@0.105.5(@envelop/core@5.5.1)(graphql@16.9.0)(ioredis@5.8.2)': + '@graphql-mesh/plugin-rate-limit@0.105.5(@envelop/core@5.5.1)(graphql@16.12.0)(ioredis@5.8.2)': dependencies: - '@envelop/rate-limiter': 9.0.0(@envelop/core@5.5.1)(graphql@16.9.0) - '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.9.0) - '@graphql-mesh/string-interpolation': 0.5.9(graphql@16.9.0) - '@graphql-mesh/types': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) - '@graphql-mesh/utils': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) - '@graphql-tools/utils': 10.9.1(graphql@16.9.0) + '@envelop/rate-limiter': 9.0.0(@envelop/core@5.5.1)(graphql@16.12.0) + '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.12.0) + '@graphql-mesh/string-interpolation': 0.5.9(graphql@16.12.0) + '@graphql-mesh/types': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-mesh/utils': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-tools/utils': 10.9.1(graphql@16.12.0) '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.9.0 + graphql: 16.12.0 tslib: 2.8.1 transitivePeerDependencies: - '@envelop/core' - '@nats-io/nats-core' - ioredis + '@graphql-mesh/plugin-response-cache@0.104.18(graphql@16.12.0)': + dependencies: + '@envelop/core': 5.5.1 + '@envelop/response-cache': 9.0.0(@envelop/core@5.5.1)(graphql@16.12.0) + '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.12.0) + '@graphql-mesh/string-interpolation': 0.5.9(graphql@16.12.0) + '@graphql-mesh/types': 0.104.16(graphql@16.12.0) + '@graphql-mesh/utils': 0.104.16(graphql@16.12.0) + '@graphql-tools/utils': 10.9.1(graphql@16.12.0) + '@graphql-yoga/plugin-response-cache': 3.15.4(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0) + '@whatwg-node/promise-helpers': 1.3.2 + cache-control-parser: 2.0.6 + graphql: 16.12.0 + graphql-yoga: 5.17.1(graphql@16.12.0) + tslib: 2.8.1 + transitivePeerDependencies: + - '@nats-io/nats-core' + - ioredis + + '@graphql-mesh/plugin-response-cache@0.104.18(graphql@16.12.0)(ioredis@5.8.2)': + dependencies: + '@envelop/core': 5.5.1 + '@envelop/response-cache': 9.0.0(@envelop/core@5.5.1)(graphql@16.12.0) + '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.12.0) + '@graphql-mesh/string-interpolation': 0.5.9(graphql@16.12.0) + '@graphql-mesh/types': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-mesh/utils': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-tools/utils': 10.9.1(graphql@16.12.0) + '@graphql-yoga/plugin-response-cache': 3.15.4(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0) + '@whatwg-node/promise-helpers': 1.3.2 + cache-control-parser: 2.0.6 + graphql: 16.12.0 + graphql-yoga: 5.17.1(graphql@16.12.0) + tslib: 2.8.1 + transitivePeerDependencies: + - '@nats-io/nats-core' + - ioredis + '@graphql-mesh/plugin-response-cache@0.104.18(graphql@16.9.0)(ioredis@5.8.2)': dependencies: '@envelop/core': 5.5.1 @@ -23056,20 +23512,28 @@ snapshots: - '@nats-io/nats-core' - ioredis - '@graphql-mesh/plugin-snapshot@0.104.16(graphql@16.9.0)(ioredis@5.8.2)': + '@graphql-mesh/plugin-snapshot@0.104.16(graphql@16.12.0)(ioredis@5.8.2)': dependencies: - '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.9.0) - '@graphql-mesh/string-interpolation': 0.5.9(graphql@16.9.0) - '@graphql-mesh/types': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) - '@graphql-mesh/utils': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) + '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.12.0) + '@graphql-mesh/string-interpolation': 0.5.9(graphql@16.12.0) + '@graphql-mesh/types': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-mesh/utils': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) '@whatwg-node/fetch': 0.10.13 - graphql: 16.9.0 + graphql: 16.12.0 minimatch: 10.2.4 tslib: 2.8.1 transitivePeerDependencies: - '@nats-io/nats-core' - ioredis + '@graphql-mesh/string-interpolation@0.5.9(graphql@16.12.0)': + dependencies: + dayjs: 1.11.18 + graphql: 16.12.0 + json-pointer: 0.6.2 + lodash.get: 4.4.2 + tslib: 2.8.1 + '@graphql-mesh/string-interpolation@0.5.9(graphql@16.9.0)': dependencies: dayjs: 1.11.18 @@ -23078,6 +23542,44 @@ snapshots: lodash.get: 4.4.2 tslib: 2.8.1 + '@graphql-mesh/transport-common@1.0.12(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0)': + dependencies: + '@envelop/core': 5.5.1 + '@graphql-hive/logger': 1.0.9(pino@10.3.0) + '@graphql-hive/pubsub': 2.1.1(ioredis@5.8.2) + '@graphql-hive/signal': 2.0.0 + '@graphql-mesh/types': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-tools/executor': 1.5.0(graphql@16.12.0) + '@graphql-tools/executor-common': 1.0.5(graphql@16.12.0) + '@graphql-tools/utils': 10.11.0(graphql@16.12.0) + graphql: 16.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@logtape/logtape' + - '@nats-io/nats-core' + - ioredis + - pino + - winston + + '@graphql-mesh/transport-common@1.0.12(graphql@16.12.0)(pino@10.3.0)': + dependencies: + '@envelop/core': 5.5.1 + '@graphql-hive/logger': 1.0.9(pino@10.3.0) + '@graphql-hive/pubsub': 2.1.1(ioredis@5.8.2) + '@graphql-hive/signal': 2.0.0 + '@graphql-mesh/types': 0.104.16(graphql@16.12.0) + '@graphql-tools/executor': 1.5.0(graphql@16.12.0) + '@graphql-tools/executor-common': 1.0.5(graphql@16.12.0) + '@graphql-tools/utils': 10.11.0(graphql@16.12.0) + graphql: 16.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@logtape/logtape' + - '@nats-io/nats-core' + - ioredis + - pino + - winston + '@graphql-mesh/transport-common@1.0.12(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0)': dependencies: '@envelop/core': 5.5.1 @@ -23097,20 +23599,20 @@ snapshots: - pino - winston - '@graphql-mesh/transport-http-callback@1.0.12(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0)': + '@graphql-mesh/transport-http-callback@1.0.12(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0)': dependencies: '@graphql-hive/signal': 2.0.0 - '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.9.0) - '@graphql-mesh/string-interpolation': 0.5.9(graphql@16.9.0) - '@graphql-mesh/transport-common': 1.0.12(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0) - '@graphql-mesh/types': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) - '@graphql-mesh/utils': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) - '@graphql-tools/executor-common': 1.0.5(graphql@16.9.0) - '@graphql-tools/utils': 10.11.0(graphql@16.9.0) + '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.12.0) + '@graphql-mesh/string-interpolation': 0.5.9(graphql@16.12.0) + '@graphql-mesh/transport-common': 1.0.12(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0) + '@graphql-mesh/types': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-mesh/utils': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-tools/executor-common': 1.0.5(graphql@16.12.0) + '@graphql-tools/utils': 10.11.0(graphql@16.12.0) '@repeaterjs/repeater': 3.0.6 '@whatwg-node/fetch': 0.10.13 '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.9.0 + graphql: 16.12.0 tslib: 2.8.1 transitivePeerDependencies: - '@logtape/logtape' @@ -23119,17 +23621,17 @@ snapshots: - pino - winston - '@graphql-mesh/transport-http@1.0.12(@types/node@24.12.2)(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0)': + '@graphql-mesh/transport-http@1.0.12(@types/node@24.12.2)(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0)': dependencies: - '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.9.0) - '@graphql-mesh/string-interpolation': 0.5.9(graphql@16.9.0) - '@graphql-mesh/transport-common': 1.0.12(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0) - '@graphql-mesh/types': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) - '@graphql-mesh/utils': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) - '@graphql-tools/executor-http': 3.0.7(@types/node@24.12.2)(graphql@16.9.0) - '@graphql-tools/utils': 10.11.0(graphql@16.9.0) + '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.12.0) + '@graphql-mesh/string-interpolation': 0.5.9(graphql@16.12.0) + '@graphql-mesh/transport-common': 1.0.12(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0) + '@graphql-mesh/types': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-mesh/utils': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-tools/executor-http': 3.0.7(@types/node@24.12.2)(graphql@16.12.0) + '@graphql-tools/utils': 10.11.0(graphql@16.12.0) '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.9.0 + graphql: 16.12.0 tslib: 2.8.1 transitivePeerDependencies: - '@logtape/logtape' @@ -23139,17 +23641,17 @@ snapshots: - pino - winston - '@graphql-mesh/transport-ws@2.0.12(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0)': + '@graphql-mesh/transport-ws@2.0.12(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0)': dependencies: - '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.9.0) - '@graphql-mesh/string-interpolation': 0.5.9(graphql@16.9.0) - '@graphql-mesh/transport-common': 1.0.12(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0) - '@graphql-mesh/types': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) - '@graphql-mesh/utils': 0.104.16(graphql@16.9.0)(ioredis@5.8.2) - '@graphql-tools/executor-graphql-ws': 3.1.3(graphql@16.9.0) - '@graphql-tools/utils': 10.11.0(graphql@16.9.0) - graphql: 16.9.0 - graphql-ws: 6.0.6(graphql@16.9.0)(ws@8.18.0) + '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.12.0) + '@graphql-mesh/string-interpolation': 0.5.9(graphql@16.12.0) + '@graphql-mesh/transport-common': 1.0.12(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0) + '@graphql-mesh/types': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-mesh/utils': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-tools/executor-graphql-ws': 3.1.3(graphql@16.12.0) + '@graphql-tools/utils': 10.11.0(graphql@16.12.0) + graphql: 16.12.0 + graphql-ws: 6.0.6(graphql@16.12.0)(ws@8.18.0) tslib: 2.8.1 ws: 8.18.0 transitivePeerDependencies: @@ -23164,6 +23666,36 @@ snapshots: - utf-8-validate - winston + '@graphql-mesh/types@0.104.16(graphql@16.12.0)': + dependencies: + '@graphql-hive/pubsub': 2.1.1(ioredis@5.8.2) + '@graphql-tools/batch-delegate': 10.0.5(graphql@16.12.0) + '@graphql-tools/delegate': 11.1.3(graphql@16.12.0) + '@graphql-tools/utils': 10.9.1(graphql@16.12.0) + '@graphql-typed-document-node/core': 3.2.0(graphql@16.12.0) + '@repeaterjs/repeater': 3.0.6 + '@whatwg-node/disposablestack': 0.0.6 + graphql: 16.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@nats-io/nats-core' + - ioredis + + '@graphql-mesh/types@0.104.16(graphql@16.12.0)(ioredis@5.8.2)': + dependencies: + '@graphql-hive/pubsub': 2.1.1(ioredis@5.8.2) + '@graphql-tools/batch-delegate': 10.0.5(graphql@16.12.0) + '@graphql-tools/delegate': 11.1.3(graphql@16.12.0) + '@graphql-tools/utils': 10.9.1(graphql@16.12.0) + '@graphql-typed-document-node/core': 3.2.0(graphql@16.12.0) + '@repeaterjs/repeater': 3.0.6 + '@whatwg-node/disposablestack': 0.0.6 + graphql: 16.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@nats-io/nats-core' + - ioredis + '@graphql-mesh/types@0.104.16(graphql@16.9.0)(ioredis@5.8.2)': dependencies: '@graphql-hive/pubsub': 2.1.1(ioredis@5.8.2) @@ -23179,6 +23711,54 @@ snapshots: - '@nats-io/nats-core' - ioredis + '@graphql-mesh/utils@0.104.16(graphql@16.12.0)': + dependencies: + '@envelop/instrumentation': 1.0.0 + '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.12.0) + '@graphql-mesh/string-interpolation': 0.5.9(graphql@16.12.0) + '@graphql-mesh/types': 0.104.16(graphql@16.12.0) + '@graphql-tools/batch-delegate': 10.0.5(graphql@16.12.0) + '@graphql-tools/delegate': 11.1.3(graphql@16.12.0) + '@graphql-tools/utils': 10.9.1(graphql@16.12.0) + '@graphql-tools/wrap': 11.1.2(graphql@16.12.0) + '@whatwg-node/disposablestack': 0.0.6 + '@whatwg-node/fetch': 0.10.13 + '@whatwg-node/promise-helpers': 1.3.2 + dset: 3.1.4 + graphql: 16.12.0 + js-yaml: 4.1.1 + lodash.get: 4.4.2 + lodash.topath: 4.5.2 + tiny-lru: 11.4.7 + tslib: 2.8.1 + transitivePeerDependencies: + - '@nats-io/nats-core' + - ioredis + + '@graphql-mesh/utils@0.104.16(graphql@16.12.0)(ioredis@5.8.2)': + dependencies: + '@envelop/instrumentation': 1.0.0 + '@graphql-mesh/cross-helpers': 0.4.10(graphql@16.12.0) + '@graphql-mesh/string-interpolation': 0.5.9(graphql@16.12.0) + '@graphql-mesh/types': 0.104.16(graphql@16.12.0)(ioredis@5.8.2) + '@graphql-tools/batch-delegate': 10.0.5(graphql@16.12.0) + '@graphql-tools/delegate': 11.1.3(graphql@16.12.0) + '@graphql-tools/utils': 10.9.1(graphql@16.12.0) + '@graphql-tools/wrap': 11.1.2(graphql@16.12.0) + '@whatwg-node/disposablestack': 0.0.6 + '@whatwg-node/fetch': 0.10.13 + '@whatwg-node/promise-helpers': 1.3.2 + dset: 3.1.4 + graphql: 16.12.0 + js-yaml: 4.1.1 + lodash.get: 4.4.2 + lodash.topath: 4.5.2 + tiny-lru: 11.4.7 + tslib: 2.8.1 + transitivePeerDependencies: + - '@nats-io/nats-core' + - ioredis + '@graphql-mesh/utils@0.104.16(graphql@16.9.0)(ioredis@5.8.2)': dependencies: '@envelop/instrumentation': 1.0.0 @@ -23211,6 +23791,15 @@ snapshots: sync-fetch: 0.6.0-2 tslib: 2.8.1 + '@graphql-tools/batch-delegate@10.0.5(graphql@16.12.0)': + dependencies: + '@graphql-tools/delegate': 11.1.3(graphql@16.12.0) + '@graphql-tools/utils': 10.11.0(graphql@16.12.0) + '@whatwg-node/promise-helpers': 1.3.2 + dataloader: 2.2.3 + graphql: 16.12.0 + tslib: 2.8.1 + '@graphql-tools/batch-delegate@10.0.5(graphql@16.9.0)': dependencies: '@graphql-tools/delegate': 11.1.3(graphql@16.9.0) @@ -23220,6 +23809,15 @@ snapshots: graphql: 16.9.0 tslib: 2.8.1 + '@graphql-tools/batch-delegate@10.0.8(graphql@16.12.0)': + dependencies: + '@graphql-tools/delegate': 12.0.2(graphql@16.12.0) + '@graphql-tools/utils': 10.11.0(graphql@16.12.0) + '@whatwg-node/promise-helpers': 1.3.2 + dataloader: 2.2.3 + graphql: 16.12.0 + tslib: 2.8.1 + '@graphql-tools/batch-delegate@10.0.8(graphql@16.9.0)': dependencies: '@graphql-tools/delegate': 12.0.2(graphql@16.9.0) @@ -23323,12 +23921,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@graphql-tools/code-file-loader@8.1.26(graphql@16.9.0)': + '@graphql-tools/code-file-loader@8.1.26(graphql@16.12.0)': dependencies: - '@graphql-tools/graphql-tag-pluck': 8.3.25(graphql@16.9.0) - '@graphql-tools/utils': 10.11.0(graphql@16.9.0) + '@graphql-tools/graphql-tag-pluck': 8.3.25(graphql@16.12.0) + '@graphql-tools/utils': 10.11.0(graphql@16.12.0) globby: 11.1.0 - graphql: 16.9.0 + graphql: 16.12.0 tslib: 2.8.1 unixify: 1.0.0 transitivePeerDependencies: @@ -23359,6 +23957,18 @@ snapshots: graphql: 16.9.0 tslib: 2.8.1 + '@graphql-tools/delegate@11.1.3(graphql@16.12.0)': + dependencies: + '@graphql-tools/batch-execute': 10.0.4(graphql@16.12.0) + '@graphql-tools/executor': 1.5.0(graphql@16.12.0) + '@graphql-tools/schema': 10.0.29(graphql@16.12.0) + '@graphql-tools/utils': 10.11.0(graphql@16.12.0) + '@repeaterjs/repeater': 3.0.6 + '@whatwg-node/promise-helpers': 1.3.2 + dataloader: 2.2.3 + graphql: 16.12.0 + tslib: 2.8.1 + '@graphql-tools/delegate@11.1.3(graphql@16.9.0)': dependencies: '@graphql-tools/batch-execute': 10.0.4(graphql@16.9.0) @@ -23430,6 +24040,12 @@ snapshots: '@graphql-tools/utils': 10.9.1(graphql@16.9.0) graphql: 16.9.0 + '@graphql-tools/executor-common@1.0.5(graphql@16.12.0)': + dependencies: + '@envelop/core': 5.5.1 + '@graphql-tools/utils': 10.11.0(graphql@16.12.0) + graphql: 16.12.0 + '@graphql-tools/executor-common@1.0.5(graphql@16.9.0)': dependencies: '@envelop/core': 5.5.1 @@ -23501,13 +24117,13 @@ snapshots: - uWebSockets.js - utf-8-validate - '@graphql-tools/executor-graphql-ws@3.1.3(graphql@16.9.0)': + '@graphql-tools/executor-graphql-ws@3.1.3(graphql@16.12.0)': dependencies: - '@graphql-tools/executor-common': 1.0.5(graphql@16.9.0) - '@graphql-tools/utils': 10.11.0(graphql@16.9.0) + '@graphql-tools/executor-common': 1.0.5(graphql@16.12.0) + '@graphql-tools/utils': 10.11.0(graphql@16.12.0) '@whatwg-node/disposablestack': 0.0.6 - graphql: 16.9.0 - graphql-ws: 6.0.6(graphql@16.9.0)(ws@8.18.0) + graphql: 16.12.0 + graphql-ws: 6.0.6(graphql@16.12.0)(ws@8.18.0) isows: 1.0.7(ws@8.18.0) tslib: 2.8.1 ws: 8.18.0 @@ -23592,21 +24208,36 @@ snapshots: transitivePeerDependencies: - '@types/node' - '@graphql-tools/executor-http@3.0.7(@types/node@24.12.2)(graphql@16.9.0)': + '@graphql-tools/executor-http@3.0.7(@types/node@24.12.2)(graphql@16.12.0)': dependencies: '@graphql-hive/signal': 2.0.0 - '@graphql-tools/executor-common': 1.0.5(graphql@16.9.0) - '@graphql-tools/utils': 10.11.0(graphql@16.9.0) + '@graphql-tools/executor-common': 1.0.5(graphql@16.12.0) + '@graphql-tools/utils': 10.11.0(graphql@16.12.0) '@repeaterjs/repeater': 3.0.6 '@whatwg-node/disposablestack': 0.0.6 '@whatwg-node/fetch': 0.10.13 '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.9.0 + graphql: 16.12.0 meros: 1.3.2(@types/node@24.12.2) tslib: 2.8.1 transitivePeerDependencies: - '@types/node' + '@graphql-tools/executor-http@3.0.7(@types/node@25.5.0)(graphql@16.12.0)': + dependencies: + '@graphql-hive/signal': 2.0.0 + '@graphql-tools/executor-common': 1.0.5(graphql@16.12.0) + '@graphql-tools/utils': 10.11.0(graphql@16.12.0) + '@repeaterjs/repeater': 3.0.6 + '@whatwg-node/disposablestack': 0.0.6 + '@whatwg-node/fetch': 0.10.13 + '@whatwg-node/promise-helpers': 1.3.2 + graphql: 16.12.0 + meros: 1.3.2(@types/node@25.5.0) + tslib: 2.8.1 + transitivePeerDependencies: + - '@types/node' + '@graphql-tools/executor-http@3.0.7(@types/node@25.5.0)(graphql@16.9.0)': dependencies: '@graphql-hive/signal': 2.0.0 @@ -23724,22 +24355,42 @@ snapshots: graphql: 16.9.0 tslib: 2.8.1 - '@graphql-tools/federation@4.2.6(@types/node@24.12.2)(graphql@16.9.0)': + '@graphql-tools/federation@4.2.6(@types/node@24.12.2)(graphql@16.12.0)': dependencies: - '@graphql-tools/delegate': 12.0.2(graphql@16.9.0) - '@graphql-tools/executor': 1.5.0(graphql@16.9.0) - '@graphql-tools/executor-http': 3.0.7(@types/node@24.12.2)(graphql@16.9.0) - '@graphql-tools/merge': 9.1.5(graphql@16.9.0) - '@graphql-tools/schema': 10.0.29(graphql@16.9.0) - '@graphql-tools/stitch': 10.1.6(graphql@16.9.0) - '@graphql-tools/utils': 10.11.0(graphql@16.9.0) - '@graphql-tools/wrap': 11.1.2(graphql@16.9.0) + '@graphql-tools/delegate': 12.0.2(graphql@16.12.0) + '@graphql-tools/executor': 1.5.0(graphql@16.12.0) + '@graphql-tools/executor-http': 3.0.7(@types/node@24.12.2)(graphql@16.12.0) + '@graphql-tools/merge': 9.1.5(graphql@16.12.0) + '@graphql-tools/schema': 10.0.29(graphql@16.12.0) + '@graphql-tools/stitch': 10.1.6(graphql@16.12.0) + '@graphql-tools/utils': 10.11.0(graphql@16.12.0) + '@graphql-tools/wrap': 11.1.2(graphql@16.12.0) '@graphql-yoga/typed-event-target': 3.0.2 '@whatwg-node/disposablestack': 0.0.6 '@whatwg-node/events': 0.1.2 '@whatwg-node/fetch': 0.10.13 '@whatwg-node/promise-helpers': 1.3.2 - graphql: 16.9.0 + graphql: 16.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@types/node' + + '@graphql-tools/federation@4.2.6(@types/node@25.5.0)(graphql@16.12.0)': + dependencies: + '@graphql-tools/delegate': 12.0.2(graphql@16.12.0) + '@graphql-tools/executor': 1.5.0(graphql@16.12.0) + '@graphql-tools/executor-http': 3.0.7(@types/node@25.5.0)(graphql@16.12.0) + '@graphql-tools/merge': 9.1.5(graphql@16.12.0) + '@graphql-tools/schema': 10.0.29(graphql@16.12.0) + '@graphql-tools/stitch': 10.1.6(graphql@16.12.0) + '@graphql-tools/utils': 10.11.0(graphql@16.12.0) + '@graphql-tools/wrap': 11.1.2(graphql@16.12.0) + '@graphql-yoga/typed-event-target': 3.0.2 + '@whatwg-node/disposablestack': 0.0.6 + '@whatwg-node/events': 0.1.2 + '@whatwg-node/fetch': 0.10.13 + '@whatwg-node/promise-helpers': 1.3.2 + graphql: 16.12.0 tslib: 2.8.1 transitivePeerDependencies: - '@types/node' @@ -23848,6 +24499,17 @@ snapshots: transitivePeerDependencies: - supports-color + '@graphql-tools/graphql-file-loader@8.1.7(graphql@16.12.0)': + dependencies: + '@graphql-tools/import': 7.1.7(graphql@16.12.0) + '@graphql-tools/utils': 10.11.0(graphql@16.12.0) + globby: 11.1.0 + graphql: 16.12.0 + tslib: 2.8.1 + unixify: 1.0.0 + transitivePeerDependencies: + - supports-color + '@graphql-tools/graphql-file-loader@8.1.7(graphql@16.9.0)': dependencies: '@graphql-tools/import': 7.1.7(graphql@16.9.0) @@ -23924,6 +24586,19 @@ snapshots: transitivePeerDependencies: - supports-color + '@graphql-tools/graphql-tag-pluck@8.3.25(graphql@16.12.0)': + dependencies: + '@babel/core': 7.28.5 + '@babel/parser': 7.29.0 + '@babel/plugin-syntax-import-assertions': 7.27.1(@babel/core@7.28.5) + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@graphql-tools/utils': 10.11.0(graphql@16.12.0) + graphql: 16.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + '@graphql-tools/graphql-tag-pluck@8.3.25(graphql@16.9.0)': dependencies: '@babel/core': 7.28.5 @@ -23961,6 +24636,16 @@ snapshots: transitivePeerDependencies: - supports-color + '@graphql-tools/import@7.1.7(graphql@16.12.0)': + dependencies: + '@graphql-tools/utils': 10.11.0(graphql@16.12.0) + '@theguild/federation-composition': 0.20.2(graphql@16.12.0) + graphql: 16.12.0 + resolve-from: 5.0.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + '@graphql-tools/import@7.1.7(graphql@16.9.0)': dependencies: '@graphql-tools/utils': 10.11.0(graphql@16.9.0) @@ -24019,11 +24704,11 @@ snapshots: p-limit: 3.1.0 tslib: 2.8.1 - '@graphql-tools/load@8.1.6(graphql@16.9.0)': + '@graphql-tools/load@8.1.6(graphql@16.12.0)': dependencies: - '@graphql-tools/schema': 10.0.29(graphql@16.9.0) - '@graphql-tools/utils': 10.11.0(graphql@16.9.0) - graphql: 16.9.0 + '@graphql-tools/schema': 10.0.29(graphql@16.12.0) + '@graphql-tools/utils': 10.11.0(graphql@16.12.0) + graphql: 16.12.0 p-limit: 3.1.0 tslib: 2.8.1 @@ -24033,6 +24718,12 @@ snapshots: graphql: 16.9.0 tslib: 2.8.1 + '@graphql-tools/merge@9.1.1(graphql@16.12.0)': + dependencies: + '@graphql-tools/utils': 10.9.1(graphql@16.12.0) + graphql: 16.12.0 + tslib: 2.8.1 + '@graphql-tools/merge@9.1.1(graphql@16.9.0)': dependencies: '@graphql-tools/utils': 10.9.1(graphql@16.9.0) @@ -24071,6 +24762,13 @@ snapshots: graphql: 16.9.0 tslib: 2.8.1 + '@graphql-tools/schema@10.0.25(graphql@16.12.0)': + dependencies: + '@graphql-tools/merge': 9.1.1(graphql@16.12.0) + '@graphql-tools/utils': 10.9.1(graphql@16.12.0) + graphql: 16.12.0 + tslib: 2.8.1 + '@graphql-tools/schema@10.0.25(graphql@16.9.0)': dependencies: '@graphql-tools/merge': 9.1.1(graphql@16.9.0) @@ -24100,6 +24798,19 @@ snapshots: tslib: 2.8.1 value-or-promise: 1.0.12 + '@graphql-tools/stitch@10.1.6(graphql@16.12.0)': + dependencies: + '@graphql-tools/batch-delegate': 10.0.8(graphql@16.12.0) + '@graphql-tools/delegate': 12.0.2(graphql@16.12.0) + '@graphql-tools/executor': 1.5.0(graphql@16.12.0) + '@graphql-tools/merge': 9.1.5(graphql@16.12.0) + '@graphql-tools/schema': 10.0.29(graphql@16.12.0) + '@graphql-tools/utils': 10.11.0(graphql@16.12.0) + '@graphql-tools/wrap': 11.1.2(graphql@16.12.0) + '@whatwg-node/promise-helpers': 1.3.2 + graphql: 16.12.0 + tslib: 2.8.1 + '@graphql-tools/stitch@10.1.6(graphql@16.9.0)': dependencies: '@graphql-tools/batch-delegate': 10.0.8(graphql@16.9.0) @@ -24133,6 +24844,13 @@ snapshots: graphql: 16.9.0 tslib: 2.8.1 + '@graphql-tools/stitching-directives@4.0.8(graphql@16.12.0)': + dependencies: + '@graphql-tools/delegate': 12.0.2(graphql@16.12.0) + '@graphql-tools/utils': 10.11.0(graphql@16.12.0) + graphql: 16.12.0 + tslib: 2.8.1 + '@graphql-tools/stitching-directives@4.0.8(graphql@16.9.0)': dependencies: '@graphql-tools/delegate': 12.0.2(graphql@16.9.0) @@ -24307,6 +25025,15 @@ snapshots: graphql: 16.9.0 tslib: 2.8.1 + '@graphql-tools/utils@10.9.1(graphql@16.12.0)': + dependencies: + '@graphql-typed-document-node/core': 3.2.0(graphql@16.12.0) + '@whatwg-node/promise-helpers': 1.3.1 + cross-inspect: 1.0.1 + dset: 3.1.4 + graphql: 16.12.0 + tslib: 2.8.1 + '@graphql-tools/utils@10.9.1(graphql@16.9.0)': dependencies: '@graphql-typed-document-node/core': 3.2.0(graphql@16.9.0) @@ -24403,6 +25130,16 @@ snapshots: dependencies: tslib: 2.8.1 + '@graphql-yoga/plugin-apollo-inline-trace@3.17.1(@envelop/core@5.5.1)(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0)': + dependencies: + '@apollo/usage-reporting-protobuf': 4.1.1 + '@envelop/on-resolve': 7.0.0(@envelop/core@5.5.1)(graphql@16.12.0) + graphql: 16.12.0 + graphql-yoga: 5.17.1(graphql@16.12.0) + tslib: 2.8.1 + transitivePeerDependencies: + - '@envelop/core' + '@graphql-yoga/plugin-apollo-inline-trace@3.17.1(@envelop/core@5.5.1)(graphql-yoga@5.17.1(graphql@16.9.0))(graphql@16.9.0)': dependencies: '@apollo/usage-reporting-protobuf': 4.1.1 @@ -24413,6 +25150,20 @@ snapshots: transitivePeerDependencies: - '@envelop/core' + '@graphql-yoga/plugin-apollo-usage-report@0.12.1(@envelop/core@5.5.1)(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0)': + dependencies: + '@apollo/server-gateway-interface': 2.0.0(graphql@16.12.0) + '@apollo/usage-reporting-protobuf': 4.1.1 + '@apollo/utils.usagereporting': 2.1.0(graphql@16.12.0) + '@graphql-tools/utils': 10.11.0(graphql@16.12.0) + '@graphql-yoga/plugin-apollo-inline-trace': 3.17.1(@envelop/core@5.5.1)(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0) + '@whatwg-node/promise-helpers': 1.3.2 + graphql: 16.12.0 + graphql-yoga: 5.17.1(graphql@16.12.0) + tslib: 2.8.1 + transitivePeerDependencies: + - '@envelop/core' + '@graphql-yoga/plugin-apollo-usage-report@0.12.1(@envelop/core@5.5.1)(graphql-yoga@5.17.1(graphql@16.9.0))(graphql@16.9.0)': dependencies: '@apollo/server-gateway-interface': 2.0.0(graphql@16.9.0) @@ -24427,10 +25178,20 @@ snapshots: transitivePeerDependencies: - '@envelop/core' + '@graphql-yoga/plugin-csrf-prevention@3.16.2(graphql-yoga@5.17.1(graphql@16.12.0))': + dependencies: + graphql-yoga: 5.17.1(graphql@16.12.0) + '@graphql-yoga/plugin-csrf-prevention@3.16.2(graphql-yoga@5.17.1(graphql@16.9.0))': dependencies: graphql-yoga: 5.17.1(graphql@16.9.0) + '@graphql-yoga/plugin-defer-stream@3.16.2(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0)': + dependencies: + '@graphql-tools/utils': 10.9.1(graphql@16.12.0) + graphql: 16.12.0 + graphql-yoga: 5.17.1(graphql@16.12.0) + '@graphql-yoga/plugin-defer-stream@3.16.2(graphql-yoga@5.17.1(graphql@16.9.0))(graphql@16.9.0)': dependencies: '@graphql-tools/utils': 10.9.1(graphql@16.9.0) @@ -24454,18 +25215,24 @@ snapshots: graphql-sse: 2.6.0(graphql@16.9.0) graphql-yoga: 5.13.3(graphql@16.9.0) - '@graphql-yoga/plugin-jwt@3.10.2(graphql-yoga@5.17.1(graphql@16.9.0))(graphql@16.9.0)': + '@graphql-yoga/plugin-jwt@3.10.2(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0)': dependencies: '@whatwg-node/promise-helpers': 1.3.2 '@whatwg-node/server-plugin-cookies': 1.0.5 - graphql: 16.9.0 - graphql-yoga: 5.17.1(graphql@16.9.0) + graphql: 16.12.0 + graphql-yoga: 5.17.1(graphql@16.12.0) jsonwebtoken: 9.0.3 jwks-rsa: 3.2.0 tslib: 2.8.1 transitivePeerDependencies: - supports-color + '@graphql-yoga/plugin-persisted-operations@3.16.2(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0)': + dependencies: + '@whatwg-node/promise-helpers': 1.3.2 + graphql: 16.12.0 + graphql-yoga: 5.17.1(graphql@16.12.0) + '@graphql-yoga/plugin-persisted-operations@3.16.2(graphql-yoga@5.17.1(graphql@16.9.0))(graphql@16.9.0)': dependencies: '@whatwg-node/promise-helpers': 1.3.2 @@ -24478,11 +25245,11 @@ snapshots: graphql: 16.9.0 graphql-yoga: 5.13.3(graphql@16.9.0) - '@graphql-yoga/plugin-prometheus@6.11.3(@envelop/core@5.5.1)(graphql-yoga@5.17.1(graphql@16.9.0))(graphql@16.9.0)(prom-client@15.1.3)': + '@graphql-yoga/plugin-prometheus@6.11.3(@envelop/core@5.5.1)(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0)(prom-client@15.1.3)': dependencies: - '@envelop/prometheus': 14.0.0(@envelop/core@5.5.1)(graphql@16.9.0)(prom-client@15.1.3) - graphql: 16.9.0 - graphql-yoga: 5.17.1(graphql@16.9.0) + '@envelop/prometheus': 14.0.0(@envelop/core@5.5.1)(graphql@16.12.0)(prom-client@15.1.3) + graphql: 16.12.0 + graphql-yoga: 5.17.1(graphql@16.12.0) prom-client: 15.1.3 transitivePeerDependencies: - '@envelop/core' @@ -24495,6 +25262,14 @@ snapshots: graphql: 16.9.0 graphql-yoga: 5.13.3(graphql@16.9.0) + '@graphql-yoga/plugin-response-cache@3.15.4(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0)': + dependencies: + '@envelop/core': 5.5.1 + '@envelop/response-cache': 7.1.3(@envelop/core@5.5.1)(graphql@16.12.0) + '@whatwg-node/promise-helpers': 1.3.0 + graphql: 16.12.0 + graphql-yoga: 5.17.1(graphql@16.12.0) + '@graphql-yoga/plugin-response-cache@3.15.4(graphql-yoga@5.17.1(graphql@16.9.0))(graphql@16.9.0)': dependencies: '@envelop/core': 5.5.1 @@ -24517,9 +25292,9 @@ snapshots: '@whatwg-node/events': 0.1.2 ioredis: 5.8.2 - '@graphql-yoga/render-graphiql@5.16.2(graphql-yoga@5.17.1(graphql@16.9.0))': + '@graphql-yoga/render-graphiql@5.16.2(graphql-yoga@5.17.1(graphql@16.12.0))': dependencies: - graphql-yoga: 5.17.1(graphql@16.9.0) + graphql-yoga: 5.17.1(graphql@16.12.0) '@graphql-yoga/subscription@5.0.5': dependencies: @@ -30244,6 +31019,16 @@ snapshots: transitivePeerDependencies: - supports-color + '@theguild/federation-composition@0.20.2(graphql@16.12.0)': + dependencies: + constant-case: 3.0.4 + debug: 4.4.3(supports-color@8.1.1) + graphql: 16.12.0 + json5: 2.2.3 + lodash.sortby: 4.7.0 + transitivePeerDependencies: + - supports-color + '@theguild/federation-composition@0.20.2(graphql@16.9.0)': dependencies: constant-case: 3.0.4 @@ -34556,12 +35341,12 @@ snapshots: lodash.merge: 4.6.2 lodash.mergewith: 4.6.2 - graphql-jit@0.8.7(graphql@16.9.0): + graphql-jit@0.8.7(graphql@16.12.0): dependencies: - '@graphql-typed-document-node/core': 3.2.0(graphql@16.9.0) + '@graphql-typed-document-node/core': 3.2.0(graphql@16.12.0) fast-json-stringify: 5.16.1 generate-function: 2.3.1 - graphql: 16.9.0 + graphql: 16.12.0 lodash.memoize: 4.1.2 lodash.merge: 4.6.2 lodash.mergewith: 4.6.2 @@ -34710,6 +35495,23 @@ snapshots: optionalDependencies: ws: 8.18.0 + graphql-yoga@5.13.3(graphql@16.12.0): + dependencies: + '@envelop/core': 5.5.1 + '@envelop/instrumentation': 1.0.0 + '@graphql-tools/executor': 1.5.0(graphql@16.12.0) + '@graphql-tools/schema': 10.0.25(graphql@16.12.0) + '@graphql-tools/utils': 10.9.1(graphql@16.12.0) + '@graphql-yoga/logger': 2.0.1 + '@graphql-yoga/subscription': 5.0.5 + '@whatwg-node/fetch': 0.10.13 + '@whatwg-node/promise-helpers': 1.3.2 + '@whatwg-node/server': 0.10.17 + dset: 3.1.4 + graphql: 16.12.0 + lru-cache: 10.2.0 + tslib: 2.8.1 + graphql-yoga@5.13.3(graphql@16.9.0): dependencies: '@envelop/core': 5.5.1 @@ -34727,6 +35529,22 @@ snapshots: lru-cache: 10.2.0 tslib: 2.8.1 + graphql-yoga@5.17.1(graphql@16.12.0): + dependencies: + '@envelop/core': 5.5.1 + '@envelop/instrumentation': 1.0.0 + '@graphql-tools/executor': 1.5.0(graphql@16.12.0) + '@graphql-tools/schema': 10.0.25(graphql@16.12.0) + '@graphql-tools/utils': 10.11.0(graphql@16.12.0) + '@graphql-yoga/logger': 2.0.1 + '@graphql-yoga/subscription': 5.0.5 + '@whatwg-node/fetch': 0.10.13 + '@whatwg-node/promise-helpers': 1.3.2 + '@whatwg-node/server': 0.10.17 + graphql: 16.12.0 + lru-cache: 10.2.0 + tslib: 2.8.1 + graphql-yoga@5.17.1(graphql@16.9.0): dependencies: '@envelop/core': 5.5.1 @@ -37223,21 +38041,21 @@ snapshots: monaco-editor@0.52.2: {} - monaco-graphql@1.7.3(graphql@16.12.0)(monaco-editor@0.52.2)(prettier@3.4.2): + monaco-graphql@1.7.3(graphql@16.12.0)(monaco-editor@0.52.2)(prettier@3.8.1): dependencies: graphql: 16.12.0 graphql-language-service: 5.5.0(graphql@16.12.0) monaco-editor: 0.52.2 picomatch-browser: 2.2.6 - prettier: 3.4.2 + prettier: 3.8.1 - monaco-graphql@1.7.3(graphql@16.9.0)(monaco-editor@0.52.2)(prettier@3.4.2): + monaco-graphql@1.7.3(graphql@16.9.0)(monaco-editor@0.52.2)(prettier@3.8.1): dependencies: graphql: 16.9.0 graphql-language-service: 5.5.0(graphql@16.9.0) monaco-editor: 0.52.2 picomatch-browser: 2.2.6 - prettier: 3.4.2 + prettier: 3.8.1 monaco-themes@0.4.4: dependencies: @@ -37981,10 +38799,6 @@ snapshots: pg-connection-string@2.9.1: {} - pg-cursor@2.19.0(pg@8.13.1): - dependencies: - pg: 8.13.1 - pg-cursor@2.19.0(pg@8.20.0): dependencies: pg: 8.20.0 @@ -38003,12 +38817,12 @@ snapshots: dependencies: pg: 8.13.1 - pg-promise@11.10.2(pg-query-stream@4.14.0(pg@8.13.1)): + pg-promise@11.10.2(pg-query-stream@4.14.0(pg@8.20.0)): dependencies: assert-options: 0.8.2 pg: 8.13.1 pg-minify: 1.6.5 - pg-query-stream: 4.14.0(pg@8.13.1) + pg-query-stream: 4.14.0(pg@8.20.0) spex: 3.4.0 transitivePeerDependencies: - pg-native @@ -38017,11 +38831,6 @@ snapshots: pg-protocol@1.7.0: {} - pg-query-stream@4.14.0(pg@8.13.1): - dependencies: - pg: 8.13.1 - pg-cursor: 2.19.0(pg@8.13.1) - pg-query-stream@4.14.0(pg@8.20.0): dependencies: pg: 8.20.0