Skip to content

Commit e354d38

Browse files
committed
feat(alerts): add saved reset first-seen alerts
Add configurable saved-reset first-seen alert rules backed by persisted metadata only. Record saved-reset events once per upstream/reset expiration while preserving impacted pool targets and one delivery per channel. Expose the rule in admin alerts, safe email/webhook summaries, read models, and operator docs.
1 parent 8b00cde commit e354d38

37 files changed

Lines changed: 3051 additions & 632 deletions

docs-site/src/content/docs/operators/alerts.mdx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,14 @@ Common upstream states include:
6262

6363
When several upstreams are affected, summarize counts by state and Pool. Don't publish account identifiers, raw emails, or provider credential details.
6464

65+
### Saved Reset First-Seen Alerts
66+
67+
The `upstream_saved_reset_banked_first_seen` rule is an optional alert rule that operators configure from the Alerts surface. It is not implicit or always-on. When enabled, it watches persisted saved-reset metadata and opens a once-only incident for an upstream identity plus reset expiration the first time that expiration is seen for a matching Pool assignment.
68+
69+
Evaluation is metadata-only. It reads stored upstream identity metadata and Pool assignments; it does not call provider APIs or refresh account data during alert evaluation. Incidents can name safe labels such as `example-pool` and `example-upstream`, sanitized expiration timestamps, rule kind, severity, and first-seen timing. They must not include raw provider credit objects, request or response bodies, bearer tokens, webhook secrets, prompts, account credentials, or other private content.
70+
71+
If the same upstream identity affects multiple Pools, incident visibility follows impacted Pool targets and the current operator authorization model. Owners can see all impacted Pools; assigned admins see only the impacted Pools they are allowed to manage plus safe visible, hidden, and total counts.
72+
6573
## Pool API key signals
6674

6775
Pool API key alerts are about runtime client access. A client can fail before routing if the key is paused, revoked, expired, scoped to the wrong Pool, or blocked by policy.

docs-site/src/content/docs/operators/upstreams.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ Auto redemption is a per-upstream opt-in policy available from the account-card
104104

105105
Saved reset observations and redemption attempts stay metadata-only on `upstream_identities.metadata` under `saved_resets` and `saved_reset_redemption`. The saved-reset observation may include sanitized expiration rows in `available_expirations`, where each row has an `expires_at` timestamp and a pooler-owned `first_seen_at` timestamp. Legacy `available_expires_at` stays available for compatibility, and `next_expires_at`, `expires_observed_at`, and `expires_refresh_attempted_at` remain sanitized aggregate fields. Historical rows are backfilled with the migration time as `first_seen_at`; future observations preserve `first_seen_at` only while the same expiration remains present, and disappeared expirations are not retained. Missing or invalid provider expiration fields are not guessed.
106106

107+
Operators can configure the optional `upstream_saved_reset_banked_first_seen` alert rule to notify once per upstream identity and saved-reset expiration when persisted metadata first reports a banked reset for a matching Pool assignment. The alert uses the same metadata-only observation boundary as the saved-reset UI; it does not call provider APIs during evaluation.
108+
107109
Policy fields live on the upstream identity as `saved_reset_auto_redeem_enabled`, `saved_reset_auto_redeem_trigger_mode`, `saved_reset_auto_redeem_quota_threshold_percent`, `saved_reset_auto_redeem_min_blocked_minutes`, and `saved_reset_auto_redeem_keep_credits`. Do not copy provider payloads, raw provider credit objects, raw credit identifiers, titles, descriptions, request ids, redemption request identifiers, token material, secrets, or account credentials into metadata, logs, docs, or tests.
108110

109111
## Readiness States

lib/codex_pooler/admin/alert_notification_query.ex

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ defmodule CodexPooler.Admin.AlertNotificationQuery do
2222
required(:severity) => String.t(),
2323
required(:state) => String.t(),
2424
required(:impacted_pools) => [impacted_pool()],
25+
required(:visible_impacted_pool_count) => non_neg_integer(),
26+
required(:hidden_impacted_pool_count) => non_neg_integer(),
27+
required(:total_impacted_pool_count) => non_neg_integer(),
2528
required(:last_seen_at) => DateTime.t(),
2629
required(:unread?) => boolean()
2730
}
@@ -110,14 +113,21 @@ defmodule CodexPooler.Admin.AlertNotificationQuery do
110113
@spec notification_rows([query_row()], [Ecto.UUID.t()]) :: [row()]
111114
defp notification_rows(rows, pool_ids) do
112115
impacted_pools_by_incident = impacted_pools_by_incident(rows, pool_ids)
116+
impacted_pool_counts_by_incident = impacted_pool_counts_by_incident(rows, pool_ids)
113117

114118
Enum.map(rows, fn {incident, receipt} ->
119+
counts =
120+
Map.get(impacted_pool_counts_by_incident, incident.id, empty_impacted_pool_counts())
121+
115122
%{
116123
id: incident.id,
117124
rule_kind: incident.rule_kind,
118125
severity: incident.severity,
119126
state: incident.state,
120127
impacted_pools: Map.get(impacted_pools_by_incident, incident.id, []),
128+
visible_impacted_pool_count: counts.visible,
129+
hidden_impacted_pool_count: counts.hidden,
130+
total_impacted_pool_count: counts.total,
121131
last_seen_at: incident.last_seen_at,
122132
unread?: Alerts.incident_notification_unread?(incident, receipt)
123133
}
@@ -147,10 +157,47 @@ defmodule CodexPooler.Admin.AlertNotificationQuery do
147157
)
148158
|> Enum.group_by(& &1.incident_id)
149159
|> Map.new(fn {incident_id, pools} ->
150-
{incident_id, Enum.map(pools, &Map.take(&1, [:id, :slug, :name]))}
160+
unique_pools = Enum.uniq_by(pools, & &1.id)
161+
162+
{incident_id, Enum.map(unique_pools, &Map.take(&1, [:id, :slug, :name]))}
163+
end)
164+
end
165+
166+
@spec impacted_pool_counts_by_incident([query_row()], [Ecto.UUID.t()]) :: %{
167+
optional(Ecto.UUID.t()) => %{
168+
required(:visible) => non_neg_integer(),
169+
required(:hidden) => non_neg_integer(),
170+
required(:total) => non_neg_integer()
171+
}
172+
}
173+
defp impacted_pool_counts_by_incident([], _pool_ids), do: %{}
174+
175+
defp impacted_pool_counts_by_incident(rows, pool_ids) do
176+
incident_ids = rows |> Enum.map(fn {incident, _receipt} -> incident.id end) |> Enum.uniq()
177+
visible_pool_ids = MapSet.new(pool_ids)
178+
179+
Repo.all(
180+
from target in AlertIncidentTarget,
181+
where: target.incident_id in ^incident_ids,
182+
select: %{incident_id: target.incident_id, pool_id: target.pool_id}
183+
)
184+
|> Enum.group_by(& &1.incident_id)
185+
|> Map.new(fn {incident_id, targets} ->
186+
unique_pool_ids = targets |> Enum.map(& &1.pool_id) |> MapSet.new()
187+
visible_count = Enum.count(unique_pool_ids, &MapSet.member?(visible_pool_ids, &1))
188+
total_count = MapSet.size(unique_pool_ids)
189+
190+
{incident_id,
191+
%{
192+
visible: visible_count,
193+
hidden: max(total_count - visible_count, 0),
194+
total: total_count
195+
}}
151196
end)
152197
end
153198

199+
defp empty_impacted_pool_counts, do: %{visible: 0, hidden: 0, total: 0}
200+
154201
@spec visible_pool_ids(Scope.t()) :: {:ok, [Ecto.UUID.t()]} | {:error, Alerts.access_error()}
155202
defp visible_pool_ids(scope) do
156203
case Alerts.list_manageable_pools(scope) do

lib/codex_pooler/alerts.ex

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,10 @@ defmodule CodexPooler.Alerts do
100100
IncidentLifecycle.record_result()
101101
defdelegate record_incident_match(attrs), to: IncidentLifecycle
102102

103+
@spec record_incident_once(IncidentLifecycle.match_attrs() | map()) ::
104+
IncidentLifecycle.record_once_result()
105+
defdelegate record_incident_once(attrs), to: IncidentLifecycle
106+
103107
@spec safe_projected_metadata_for_admin(map()) :: map()
104108
defdelegate safe_projected_metadata_for_admin(metadata), to: IncidentNotifications
105109

lib/codex_pooler/alerts/email_delivery.ex

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,17 @@ defmodule CodexPooler.Alerts.EmailDelivery do
2929
)
3030
@safe_summary_keys ~w(
3131
assignment_count
32+
available_count
3233
enabled_assignment_count
3334
impacted_pool_count
3435
model
36+
path_style
3537
quota_state
3638
reason_code
39+
reset_expires_at
40+
reset_first_seen_at
3741
routing_usable
42+
source
3843
status
3944
target_state
4045
threshold_used_percent
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
defmodule CodexPooler.Alerts.EvaluationCandidate do
2+
@moduledoc """
3+
Builds generic alert evaluation match/clear candidates and stable dedupe keys.
4+
"""
5+
6+
alias CodexPooler.Alerts.IncidentLifecycle
7+
alias CodexPooler.Alerts.Schemas.AlertRule
8+
alias CodexPooler.Upstreams.Quota
9+
10+
@type action :: :match | :clear
11+
@type candidate :: %{
12+
required(:action) => action(),
13+
required(:dedupe_key) => String.t(),
14+
required(:rule_id) => Ecto.UUID.t(),
15+
required(:rule_kind) => AlertRule.rule_kind(),
16+
optional(:match_attrs) => IncidentLifecycle.match_attrs(),
17+
optional(:clear_attrs) => IncidentLifecycle.clear_attrs()
18+
}
19+
20+
@spec pool_match(AlertRule.t(), map(), String.t(), DateTime.t()) :: candidate()
21+
def pool_match(%AlertRule{} = rule, projection, reason_code, %DateTime{} = timestamp) do
22+
dedupe_key = dedupe_key_for_rule(rule, nil)
23+
24+
match(rule, dedupe_key, timestamp, %{
25+
pool_id: rule.pool_id,
26+
safe_evidence_snapshot: %{
27+
"reason_code" => reason_code,
28+
"pool_id" => rule.pool_id,
29+
"model" => rule.model,
30+
"assignment_count" => projection.assignment_count,
31+
"enabled_assignment_count" => projection.enabled_assignment_count,
32+
"usable_assignment_count" => projection.usable_assignment_count,
33+
"state_counts" => stringify_count_keys(projection.state_counts),
34+
"min_usable_assignments" => rule.min_usable_assignments,
35+
"target_state" => rule.target_state
36+
},
37+
targets: [target(rule, rule.pool_id, %{reason_code: reason_code})]
38+
})
39+
end
40+
41+
@spec threshold(AlertRule.t(), map(), DateTime.t()) :: candidate()
42+
def threshold(%AlertRule{} = rule, assignment, %DateTime{} = timestamp) do
43+
dedupe_key = dedupe_key_for_rule(rule, assignment.upstream_identity_id)
44+
threshold = rule.threshold_used_percent || Decimal.new(100)
45+
selector = rule.window_selector || "any"
46+
windows = selected_windows(assignment.quota_windows, selector, rule.model, timestamp)
47+
match = Enum.find(windows, &threshold_match?(&1, threshold, timestamp))
48+
49+
if match do
50+
upstream_match(rule, dedupe_key, assignment, "quota_threshold", timestamp, %{
51+
window_selector: selector,
52+
threshold_used_percent: decimal_string(threshold),
53+
used_percent: decimal_float(match.used_percent),
54+
reset_at: iso8601_or_nil(match.reset_at),
55+
quota_key: match.quota_key,
56+
window_kind: match.window_kind,
57+
quota_scope: match.quota_scope,
58+
model: match.model,
59+
upstream_model: match.upstream_model
60+
})
61+
else
62+
clear(rule, dedupe_key, timestamp)
63+
end
64+
end
65+
66+
@spec auth_state(AlertRule.t(), map(), DateTime.t()) :: candidate()
67+
def auth_state(%AlertRule{} = rule, assignment, %DateTime{} = timestamp) do
68+
dedupe_key = dedupe_key_for_rule(rule, assignment.upstream_identity_id)
69+
target_state = rule.target_state
70+
71+
if assignment.identity_status == target_state do
72+
upstream_match(rule, dedupe_key, assignment, target_state, timestamp, %{
73+
target_state: target_state,
74+
identity_status: assignment.identity_status
75+
})
76+
else
77+
clear(rule, dedupe_key, timestamp)
78+
end
79+
end
80+
81+
@spec clear(AlertRule.t(), String.t(), DateTime.t()) :: candidate()
82+
def clear(%AlertRule{} = rule, dedupe_key, %DateTime{} = timestamp) do
83+
%{
84+
action: :clear,
85+
dedupe_key: dedupe_key,
86+
rule_id: rule.id,
87+
rule_kind: rule.rule_kind,
88+
clear_attrs: %{dedupe_key: dedupe_key, cleared_at: timestamp}
89+
}
90+
end
91+
92+
@spec dedupe_key_for_rule(AlertRule.t(), Ecto.UUID.t() | nil) :: String.t()
93+
def dedupe_key_for_rule(
94+
%AlertRule{rule_kind: "upstream_quota_threshold"} = rule,
95+
upstream_identity_id
96+
) do
97+
Enum.join(
98+
[
99+
"alerts",
100+
"v1",
101+
rule.rule_kind,
102+
"upstream_identity",
103+
upstream_identity_id || "none",
104+
"window",
105+
rule.window_selector || "any",
106+
"threshold",
107+
decimal_string(rule.threshold_used_percent || Decimal.new(100)),
108+
"model",
109+
rule.model || "any"
110+
],
111+
":"
112+
)
113+
end
114+
115+
def dedupe_key_for_rule(
116+
%AlertRule{rule_kind: "upstream_auth_state"} = rule,
117+
upstream_identity_id
118+
) do
119+
Enum.join(
120+
[
121+
"alerts",
122+
"v1",
123+
rule.rule_kind,
124+
"upstream_identity",
125+
upstream_identity_id || "none",
126+
"state",
127+
rule.target_state || "unknown"
128+
],
129+
":"
130+
)
131+
end
132+
133+
def dedupe_key_for_rule(%AlertRule{} = rule, _upstream_identity_id) do
134+
Enum.join(
135+
[
136+
"alerts",
137+
"v1",
138+
rule.rule_kind,
139+
"pool",
140+
rule.pool_id,
141+
"model",
142+
rule.model || "any",
143+
"min",
144+
rule.min_usable_assignments || "none",
145+
"state",
146+
rule.target_state || "none"
147+
],
148+
":"
149+
)
150+
end
151+
152+
defp upstream_match(rule, dedupe_key, assignment, reason_code, timestamp, metadata) do
153+
match(rule, dedupe_key, timestamp, %{
154+
upstream_identity_id: assignment.upstream_identity_id,
155+
safe_evidence_snapshot:
156+
Map.merge(metadata, %{
157+
reason_code: reason_code,
158+
pool_id: rule.pool_id,
159+
upstream_identity_id: assignment.upstream_identity_id,
160+
pool_upstream_assignment_id: assignment.assignment_id,
161+
model: rule.model
162+
}),
163+
targets: [target(rule, rule.pool_id, metadata)]
164+
})
165+
end
166+
167+
defp match(rule, dedupe_key, timestamp, attrs) do
168+
match_attrs = %{
169+
dedupe_key: dedupe_key,
170+
scope_type: rule.scope_type,
171+
rule_kind: rule.rule_kind,
172+
severity: severity_for(rule),
173+
pool_id: Map.get(attrs, :pool_id),
174+
upstream_identity_id: Map.get(attrs, :upstream_identity_id),
175+
safe_evidence_snapshot: Map.fetch!(attrs, :safe_evidence_snapshot),
176+
targets: Map.fetch!(attrs, :targets),
177+
matched_at: timestamp
178+
}
179+
180+
%{
181+
action: :match,
182+
dedupe_key: dedupe_key,
183+
rule_id: rule.id,
184+
rule_kind: rule.rule_kind,
185+
match_attrs: match_attrs
186+
}
187+
end
188+
189+
defp threshold_match?(%Quota.AccountQuotaWindow{} = window, threshold, timestamp) do
190+
Quota.Windows.fresh_window?(window, timestamp) and not is_nil(window.used_percent) and
191+
Decimal.compare(window.used_percent, threshold) != :lt
192+
end
193+
194+
defp selected_windows(windows, "any", model, timestamp) do
195+
opts = model_opts(model) ++ [at: timestamp]
196+
197+
windows
198+
|> Quota.Windows.quota_window_selection_data_from_windows(opts)
199+
|> Map.fetch!(:routing_windows)
200+
end
201+
202+
defp selected_windows(windows, selector, model, timestamp) do
203+
windows
204+
|> selected_windows("any", model, timestamp)
205+
|> Enum.filter(&(window_selector(&1) == selector))
206+
end
207+
208+
defp window_selector(%Quota.AccountQuotaWindow{} = window) do
209+
cond do
210+
model_window?(window) and window.window_kind == "secondary" -> "model_secondary"
211+
model_window?(window) -> "model_primary"
212+
window.window_kind == "secondary" -> "account_secondary"
213+
true -> "account_primary"
214+
end
215+
end
216+
217+
defp model_window?(%Quota.AccountQuotaWindow{} = window) do
218+
window.quota_scope in ["model", "upstream_model"] or present_string?(window.model) or
219+
present_string?(window.upstream_model)
220+
end
221+
222+
defp target(rule, pool_id, metadata) do
223+
%{rule_id: rule.id, pool_id: pool_id, metadata: stringify_metadata(metadata)}
224+
end
225+
226+
defp severity_for(%AlertRule{
227+
rule_kind: "upstream_auth_state",
228+
target_state: "reauth_required"
229+
}),
230+
do: "critical"
231+
232+
defp severity_for(%AlertRule{severity: severity}), do: severity
233+
234+
defp model_opts(nil), do: []
235+
defp model_opts(model), do: [model: model]
236+
237+
defp stringify_count_keys(counts),
238+
do: Map.new(counts, fn {key, value} -> {to_string(key), value} end)
239+
240+
defp stringify_metadata(metadata),
241+
do: Map.new(metadata, fn {key, value} -> {to_string(key), value} end)
242+
243+
defp decimal_string(%Decimal{} = value), do: Decimal.to_string(value, :normal)
244+
defp decimal_string(value) when is_integer(value), do: Integer.to_string(value)
245+
246+
defp decimal_float(%Decimal{} = value), do: Decimal.to_float(value)
247+
defp decimal_float(_value), do: nil
248+
249+
defp iso8601_or_nil(%DateTime{} = value), do: DateTime.to_iso8601(value)
250+
defp iso8601_or_nil(_value), do: nil
251+
252+
defp present_string?(value) when is_binary(value), do: String.trim(value) != ""
253+
defp present_string?(_value), do: false
254+
end

0 commit comments

Comments
 (0)