Skip to content

Commit 4434113

Browse files
feat: add traffic intelligence features (Phase 10)
Traffic splitting with weighted/conditional routing, configuration templates with 6 built-in presets, enhanced diff viewer with side-by-side mode and semantic summary, and request-level analytics with metrics ingestion, pruning, and per-service dashboards. 598 tests passing (42 new).
1 parent 16f4a21 commit 4434113

36 files changed

Lines changed: 3386 additions & 28 deletions

lib/sentinel_cp/analytics.ex

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
defmodule SentinelCp.Analytics do
2+
@moduledoc """
3+
The Analytics context for request-level metrics and logs.
4+
"""
5+
6+
import Ecto.Query, warn: false
7+
alias SentinelCp.Repo
8+
alias SentinelCp.Analytics.{ServiceMetric, RequestLog}
9+
10+
## Ingestion
11+
12+
@doc """
13+
Bulk inserts metric records from a node push.
14+
"""
15+
def ingest_metrics(metrics_list) when is_list(metrics_list) do
16+
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
17+
18+
entries =
19+
Enum.map(metrics_list, fn attrs ->
20+
%{
21+
id: Ecto.UUID.generate(),
22+
service_id: attrs["service_id"],
23+
project_id: attrs["project_id"],
24+
period_start: parse_datetime(attrs["period_start"]),
25+
period_seconds: attrs["period_seconds"] || 60,
26+
request_count: attrs["request_count"] || 0,
27+
error_count: attrs["error_count"] || 0,
28+
latency_p50_ms: attrs["latency_p50_ms"],
29+
latency_p95_ms: attrs["latency_p95_ms"],
30+
latency_p99_ms: attrs["latency_p99_ms"],
31+
bandwidth_in_bytes: attrs["bandwidth_in_bytes"] || 0,
32+
bandwidth_out_bytes: attrs["bandwidth_out_bytes"] || 0,
33+
status_2xx: attrs["status_2xx"] || 0,
34+
status_3xx: attrs["status_3xx"] || 0,
35+
status_4xx: attrs["status_4xx"] || 0,
36+
status_5xx: attrs["status_5xx"] || 0,
37+
top_paths: attrs["top_paths"] || %{},
38+
top_consumers: attrs["top_consumers"] || %{},
39+
inserted_at: now,
40+
updated_at: now
41+
}
42+
end)
43+
44+
{count, _} = Repo.insert_all(ServiceMetric, entries)
45+
{:ok, count}
46+
end
47+
48+
@doc """
49+
Bulk inserts request log entries from a node push.
50+
"""
51+
def ingest_request_logs(logs_list) when is_list(logs_list) do
52+
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
53+
54+
entries =
55+
Enum.map(logs_list, fn attrs ->
56+
%{
57+
id: Ecto.UUID.generate(),
58+
service_id: attrs["service_id"],
59+
project_id: attrs["project_id"],
60+
node_id: attrs["node_id"],
61+
timestamp: parse_datetime_usec(attrs["timestamp"]),
62+
method: attrs["method"],
63+
path: attrs["path"],
64+
status: attrs["status"],
65+
latency_ms: attrs["latency_ms"],
66+
client_ip: attrs["client_ip"],
67+
user_agent: attrs["user_agent"],
68+
request_size: attrs["request_size"],
69+
response_size: attrs["response_size"],
70+
inserted_at: now,
71+
updated_at: now
72+
}
73+
end)
74+
75+
{count, _} = Repo.insert_all(RequestLog, entries)
76+
{:ok, count}
77+
end
78+
79+
## Queries
80+
81+
@doc """
82+
Returns time-series metrics for a service within a time range.
83+
"""
84+
def get_service_metrics(service_id, time_range, opts \\ []) do
85+
limit = Keyword.get(opts, :limit, 1000)
86+
{start_time, end_time} = resolve_time_range(time_range)
87+
88+
from(m in ServiceMetric,
89+
where: m.service_id == ^service_id,
90+
where: m.period_start >= ^start_time,
91+
where: m.period_start <= ^end_time,
92+
order_by: [asc: m.period_start],
93+
limit: ^limit
94+
)
95+
|> Repo.all()
96+
end
97+
98+
@doc """
99+
Returns aggregated metrics across all services in a project for a time range.
100+
"""
101+
def get_project_metrics(project_id, time_range) do
102+
{start_time, end_time} = resolve_time_range(time_range)
103+
104+
from(m in ServiceMetric,
105+
where: m.project_id == ^project_id,
106+
where: m.period_start >= ^start_time,
107+
where: m.period_start <= ^end_time,
108+
select: %{
109+
total_requests: sum(m.request_count),
110+
total_errors: sum(m.error_count),
111+
avg_latency_p50: avg(m.latency_p50_ms),
112+
avg_latency_p95: avg(m.latency_p95_ms),
113+
avg_latency_p99: avg(m.latency_p99_ms),
114+
total_bandwidth_in: sum(m.bandwidth_in_bytes),
115+
total_bandwidth_out: sum(m.bandwidth_out_bytes),
116+
total_2xx: sum(m.status_2xx),
117+
total_3xx: sum(m.status_3xx),
118+
total_4xx: sum(m.status_4xx),
119+
total_5xx: sum(m.status_5xx)
120+
}
121+
)
122+
|> Repo.one()
123+
|> normalize_aggregation()
124+
end
125+
126+
@doc """
127+
Returns per-service metrics sorted by request count for a project.
128+
"""
129+
def get_top_services(project_id, time_range) do
130+
{start_time, end_time} = resolve_time_range(time_range)
131+
132+
from(m in ServiceMetric,
133+
where: m.project_id == ^project_id,
134+
where: m.period_start >= ^start_time,
135+
where: m.period_start <= ^end_time,
136+
group_by: m.service_id,
137+
select: %{
138+
service_id: m.service_id,
139+
total_requests: sum(m.request_count),
140+
total_errors: sum(m.error_count),
141+
avg_latency_p50: avg(m.latency_p50_ms),
142+
avg_latency_p95: avg(m.latency_p95_ms),
143+
avg_latency_p99: avg(m.latency_p99_ms),
144+
total_bandwidth_in: sum(m.bandwidth_in_bytes),
145+
total_bandwidth_out: sum(m.bandwidth_out_bytes)
146+
},
147+
order_by: [desc: sum(m.request_count)]
148+
)
149+
|> Repo.all()
150+
end
151+
152+
@doc """
153+
Returns status code distribution for a service.
154+
"""
155+
def get_status_distribution(service_id, time_range) do
156+
{start_time, end_time} = resolve_time_range(time_range)
157+
158+
from(m in ServiceMetric,
159+
where: m.service_id == ^service_id,
160+
where: m.period_start >= ^start_time,
161+
where: m.period_start <= ^end_time,
162+
select: %{
163+
status_2xx: sum(m.status_2xx),
164+
status_3xx: sum(m.status_3xx),
165+
status_4xx: sum(m.status_4xx),
166+
status_5xx: sum(m.status_5xx)
167+
}
168+
)
169+
|> Repo.one()
170+
|> normalize_aggregation()
171+
end
172+
173+
@doc """
174+
Returns recent request logs for a service.
175+
"""
176+
def get_recent_logs(service_id, opts \\ []) do
177+
limit = Keyword.get(opts, :limit, 50)
178+
179+
from(l in RequestLog,
180+
where: l.service_id == ^service_id,
181+
order_by: [desc: l.timestamp],
182+
limit: ^limit
183+
)
184+
|> Repo.all()
185+
end
186+
187+
@doc """
188+
Deletes request logs older than the retention period.
189+
Returns the number of deleted records.
190+
"""
191+
def prune_old_logs(retention_hours \\ 24) do
192+
cutoff = DateTime.utc_now() |> DateTime.add(-retention_hours * 3600, :second)
193+
194+
{count, _} =
195+
from(l in RequestLog, where: l.timestamp < ^cutoff)
196+
|> Repo.delete_all()
197+
198+
{:ok, count}
199+
end
200+
201+
## Private
202+
203+
defp resolve_time_range(hours) when is_integer(hours) do
204+
end_time = DateTime.utc_now()
205+
start_time = DateTime.add(end_time, -hours * 3600, :second)
206+
{start_time, end_time}
207+
end
208+
209+
defp resolve_time_range({start_time, end_time}), do: {start_time, end_time}
210+
211+
defp parse_datetime(nil), do: DateTime.utc_now() |> DateTime.truncate(:second)
212+
213+
defp parse_datetime(str) when is_binary(str) do
214+
case DateTime.from_iso8601(str) do
215+
{:ok, dt, _} -> DateTime.truncate(dt, :second)
216+
_ -> DateTime.utc_now() |> DateTime.truncate(:second)
217+
end
218+
end
219+
220+
defp parse_datetime(%DateTime{} = dt), do: dt
221+
222+
defp parse_datetime_usec(nil), do: DateTime.utc_now()
223+
224+
defp parse_datetime_usec(str) when is_binary(str) do
225+
case DateTime.from_iso8601(str) do
226+
{:ok, dt, _} -> dt
227+
_ -> DateTime.utc_now()
228+
end
229+
end
230+
231+
defp parse_datetime_usec(%DateTime{} = dt), do: dt
232+
233+
defp normalize_aggregation(nil) do
234+
%{
235+
total_requests: 0,
236+
total_errors: 0,
237+
avg_latency_p50: nil,
238+
avg_latency_p95: nil,
239+
avg_latency_p99: nil,
240+
total_bandwidth_in: 0,
241+
total_bandwidth_out: 0,
242+
total_2xx: 0,
243+
total_3xx: 0,
244+
total_4xx: 0,
245+
total_5xx: 0,
246+
status_2xx: 0,
247+
status_3xx: 0,
248+
status_4xx: 0,
249+
status_5xx: 0
250+
}
251+
end
252+
253+
defp normalize_aggregation(result) when is_map(result) do
254+
Map.new(result, fn
255+
{k, nil} when k in [:total_requests, :total_errors, :total_bandwidth_in, :total_bandwidth_out,
256+
:total_2xx, :total_3xx, :total_4xx, :total_5xx,
257+
:status_2xx, :status_3xx, :status_4xx, :status_5xx] ->
258+
{k, 0}
259+
260+
{k, %Decimal{} = v} ->
261+
{k, Decimal.to_float(v) |> Float.round(1)}
262+
263+
{k, v} ->
264+
{k, v}
265+
end)
266+
end
267+
end
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
defmodule SentinelCp.Analytics.PruneWorker do
2+
@moduledoc """
3+
Oban worker that prunes old request logs.
4+
5+
Runs hourly and deletes request_logs older than the retention period (default 24h).
6+
"""
7+
use Oban.Worker,
8+
queue: :maintenance,
9+
max_attempts: 1,
10+
unique: [period: 3600]
11+
12+
require Logger
13+
14+
alias SentinelCp.Analytics
15+
16+
@check_interval_seconds 3_600
17+
18+
@impl Oban.Worker
19+
def perform(%Oban.Job{}) do
20+
retention_hours = Application.get_env(:sentinel_cp, :analytics_retention_hours, 24)
21+
22+
case Analytics.prune_old_logs(retention_hours) do
23+
{:ok, 0} ->
24+
Logger.debug("PruneWorker: no old request logs to prune")
25+
26+
{:ok, count} ->
27+
Logger.info("PruneWorker: pruned #{count} old request logs")
28+
end
29+
30+
schedule_next()
31+
:ok
32+
end
33+
34+
@doc """
35+
Ensures the prune worker is scheduled. Safe to call multiple times.
36+
"""
37+
def ensure_started do
38+
oban_config = Application.get_env(:sentinel_cp, Oban, [])
39+
40+
unless oban_config[:testing] do
41+
%{} |> __MODULE__.new(schedule_in: 60) |> Oban.insert()
42+
end
43+
end
44+
45+
defp schedule_next do
46+
oban_config = Application.get_env(:sentinel_cp, Oban, [])
47+
48+
unless oban_config[:testing] do
49+
%{} |> __MODULE__.new(schedule_in: @check_interval_seconds) |> Oban.insert()
50+
end
51+
end
52+
end
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
defmodule SentinelCp.Analytics.RequestLog do
2+
use Ecto.Schema
3+
import Ecto.Changeset
4+
5+
@primary_key {:id, :binary_id, autogenerate: true}
6+
@foreign_key_type :binary_id
7+
schema "request_logs" do
8+
belongs_to :service, SentinelCp.Services.Service
9+
belongs_to :project, SentinelCp.Projects.Project
10+
belongs_to :node, SentinelCp.Nodes.Node
11+
12+
field :timestamp, :utc_datetime_usec
13+
field :method, :string
14+
field :path, :string
15+
field :status, :integer
16+
field :latency_ms, :integer
17+
field :client_ip, :string
18+
field :user_agent, :string
19+
field :request_size, :integer
20+
field :response_size, :integer
21+
22+
timestamps()
23+
end
24+
25+
def changeset(log, attrs) do
26+
log
27+
|> cast(attrs, [
28+
:service_id,
29+
:project_id,
30+
:node_id,
31+
:timestamp,
32+
:method,
33+
:path,
34+
:status,
35+
:latency_ms,
36+
:client_ip,
37+
:user_agent,
38+
:request_size,
39+
:response_size
40+
])
41+
|> validate_required([:service_id, :project_id, :timestamp])
42+
end
43+
end

0 commit comments

Comments
 (0)