Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 27 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,9 +157,33 @@ The `type` field for a JSON payload check must be `"json"`.

The `keypath` field is an array of key selectors that "select" values from nested json.

The `expects` field is a string that declaratively indicates what checks to
perform on the value selected by `keypath`. The allowed validators for
`expects` are: `"not_empty"` and `"jsonapi"`.
The `expects` field is a string or object that declaratively indicates what checks to
perform on the value selected by `keypath`. The allowed stringy validators for
`expects` are: `"not_empty"` and `"jsonapi"`. `"not_empty"` simply checks that the
result is non-empty, whereas `"json_api"` checks that the response has a valid JSON:API
version.

Valid object values for the `expects` field are either of the following:

```json
{ "expectation": "min_length", "min_length": 100 }
```

This check asserts that the returned `keypath` is an array of at least a certain length,
in this example 100.

Alternatively, for a more dynamic array length check, one can use the following form of `expects` value:

```json
{ "expectation": "active_schedule_min_length", "routes": ["Red", "Orange", "Blue"], "multiplier": 0.1 }
```

This dynamically checks the v3 API `/schedules` endpoint for the indicated routes for
a two-hour window centered around the current time (with some caching) and multiplies
the count of records returned by `multiplier` to get the desired minimum length. This
is to handle checks for things like predictions where the number of expected predictions
is going to depend on the level of service that is currently active. Based on some initial
empirical observation, a multiplier of around 0.05 seems appropriate for predictions.

## Initial checks

Expand Down
1 change: 1 addition & 0 deletions lib/api_checker/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ defmodule ApiChecker.Application do
# {ApiChecker.Worker, arg},
# {ApiChecker.Schedule, nil},
{ApiChecker.Holiday, name: ApiChecker.Holiday},
{ApiChecker.ScheduleCountCache, nil},
{ApiChecker.PreviousResponse, nil},
{ApiChecker.Scheduler, nil}
]
Expand Down
30 changes: 30 additions & 0 deletions lib/api_checker/check/json_check.ex
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ defmodule ApiChecker.Check.JsonCheck do
"""

alias ApiChecker.Check.{JsonCheck, Params}
alias ApiChecker.ScheduleCountCache
alias JsonCheck.{Array, Jsonapi}

defstruct keypath: [],
Expand Down Expand Up @@ -76,6 +77,35 @@ defmodule ApiChecker.Check.JsonCheck do
when is_integer(min_length) and min_length > 0,
do: {:ok, &Array.validate_min_length(&1, min_length)}

def get_expectation_func(%{
"expectation" => "active_schedule_min_length",
"routes" => routes,
"multiplier" => multiplier
})
when is_list(routes) and routes != [] and is_number(multiplier) and multiplier > 0 do
{:ok,
fn list ->
case ScheduleCountCache.get_count(routes) do
{:ok, schedule_count} ->
min_length = floor(schedule_count * multiplier)

case Array.validate_min_length(list, min_length) do
{:ok, length: length} ->
{:ok, length: length, min_length: min_length}

{:error, :array_too_small, length: length} ->
{:error, :array_too_small, length: length, min_length: min_length}

other ->
other
end

{:error, reason} ->
{:error, :schedule_count_unavailable, reason: reason}
end
end}
end

def get_expectation_func(_), do: {:error, :no_such_expectation}

@doc """
Expand Down
132 changes: 132 additions & 0 deletions lib/api_checker/schedule_count_cache.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
defmodule ApiChecker.ScheduleCountCache do
@moduledoc """
A GenServer that caches the count of scheduled stop times per route-set by
querying the MBTA V3 API. Cache entries are invalidated after a configurable
TTL (default: 30 minutes).

The MBTA V3 endpoint used is:
GET /schedules?filter[route]=<routes>&filter[date]=<date>&filter[min_time]=<HH:MM>&filter[max_time]=<HH:MM>

A 2-hour window (+/-1 hour around now) is used for `min_time`/`max_time` to keep the
response small.

The count is the number of entries in the `data` array of the response that have a
matching `relationships.route.data.id` (to exclude related routes like shuttles).
"""

use GenServer
require Logger

alias ApiChecker.Utilities

@window_hours 1
@default_ttl_seconds 60 * 30
@default_base_url "https://api-v3.mbta.com"

def start_link(_opts) do
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
end

@doc """
Returns `{:ok, count}` for the number of schedules on the given routes in a 2-hour
window around now, using a cached value if one exists within `ttl_seconds`.
Returns `{:error, reason}` on failure.
"""
def get_count(routes, ttl_seconds \\ @default_ttl_seconds)
when is_list(routes) and routes != [] do
GenServer.call(__MODULE__, {:get_count, routes, ttl_seconds})
end

@impl GenServer
def init(state) do
{:ok, state}
end

@impl GenServer
def handle_call({:get_count, routes, ttl_seconds}, _from, state) do
key = cache_key(routes)
now = System.os_time(:second)

case Map.get(state, key) do
{count, fetched_at} when now - fetched_at < ttl_seconds ->
{:reply, {:ok, count}, state}

_ ->
{reply, new_state} = do_fetch(routes, key, now, state)
{:reply, reply, new_state}
end
end

defp do_fetch(routes, key, now, state) do
case fetch_schedule_count(routes) do
{:ok, count} ->
{{:ok, count}, Map.put(state, key, {count, now})}

{:error, reason} = err ->
Logger.info(fn ->
"ScheduleCountCache fetch failed routes=#{inspect(routes)} reason=#{inspect(reason)}"
end)

{err, state}
end
end

defp cache_key(routes) do
routes |> Enum.sort() |> Enum.join(",")
end

defp fetch_schedule_count(routes) do
base_url = Application.get_env(:api_checker, :schedule_count_base_url, @default_base_url)
route_param = routes |> Enum.sort() |> Enum.join(",")

now_service =
DateTime.shift_zone!(DateTime.utc_now(), "America/New_York", Tzdata.TimeZoneDatabase)

{service_date, gtfs_minutes_now} = Utilities.service_date_and_gtfs_minutes(now_service)
min_time = Utilities.format_gtfs_time(gtfs_minutes_now - @window_hours * 60)
max_time = Utilities.format_gtfs_time(gtfs_minutes_now + @window_hours * 60)
date_param = Date.to_iso8601(service_date)

url =
"#{base_url}/schedules" <>
"?filter[route]=#{URI.encode(route_param)}" <>
"&filter[date]=#{date_param}" <>
"&filter[min_time]=#{min_time}" <>
"&filter[max_time]=#{max_time}"

case HTTPoison.get(url, [], timeout: 10_000, recv_timeout: 10_000) do
{:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
parse_schedules(body, routes)

{:ok, %HTTPoison.Response{status_code: status_code}} ->
{:error, {:unexpected_status, status_code}}

{:error, %HTTPoison.Error{reason: reason}} ->
{:error, reason}
end
end

defp parse_schedules(body, routes) do
case Jason.decode(body) do
{:ok, %{"data" => data}} when is_list(data) ->
route_set = MapSet.new(routes)
count = Enum.count(data, &schedule_on_route?(&1, route_set))
{:ok, count}

{:ok, _} ->
{:error, :unexpected_response_shape}

{:error, reason} ->
{:error, {:json_decode_error, reason}}
end
end

defp schedule_on_route?(
%{"relationships" => %{"route" => %{"data" => %{"id" => route_id}}}},
route_set
) do
MapSet.member?(route_set, route_id)
end

defp schedule_on_route?(_, _), do: false
end
54 changes: 54 additions & 0 deletions lib/api_checker/utilities.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
defmodule ApiChecker.Utilities do
@moduledoc """
General utility functions for ApiChecker.
"""

# The API considers 3 AM to be the rollover from one service day to another
@service_day_start_hour 3

@doc """
Given a `DateTime` in the service timezone, returns the service date and the GTFS
time as total minutes since midnight of that service date.

## Examples

iex> dt = %DateTime{year: 2024, month: 6, day: 15, hour: 10, minute: 30, second: 0,
...> time_zone: "America/New_York", zone_abbr: "EDT", utc_offset: -18000, std_offset: 3600}
iex> Utilities.service_date_and_gtfs_minutes(dt)
{~D[2024-06-15], 630}

iex> dt = %DateTime{year: 2024, month: 6, day: 15, hour: 1, minute: 0, second: 0,
...> time_zone: "America/New_York", zone_abbr: "EDT", utc_offset: -18000, std_offset: 3600}
iex> Utilities.service_date_and_gtfs_minutes(dt)
{~D[2024-06-14], 1500}
"""
def service_date_and_gtfs_minutes(%DateTime{} = dt) do
if dt.hour < @service_day_start_hour do
service_date = Date.add(DateTime.to_date(dt), -1)
gtfs_minutes = (dt.hour + 24) * 60 + dt.minute
{service_date, gtfs_minutes}
else
{DateTime.to_date(dt), dt.hour * 60 + dt.minute}
end
end

@doc """
Formats a GTFS minute count (which may exceed 24 * 60) as a "HH:MM" string.

## Examples

iex> Utilities.format_gtfs_time(630)
"10:30"

iex> Utilities.format_gtfs_time(1500)
"25:00"

iex> Utilities.format_gtfs_time(245)
"04:05"
"""
def format_gtfs_time(minutes) when is_integer(minutes) and minutes >= 0 do
h = div(minutes, 60)
m = rem(minutes, 60)
"#{String.pad_leading(to_string(h), 2, "0")}:#{String.pad_leading(to_string(m), 2, "0")}"
end
end
78 changes: 73 additions & 5 deletions priv/dev_checks_config.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[
{
"name": "subway-predictions",
"url": "https://api-v3.mbta.com/predictions?filter[route]=Red,Orange,Blue,Green-B,Green-C,Green-D,Green-E,Mattapan",
"name": "light-rail-predictions",
"url": "https://api-v3.mbta.com/predictions?filter[route]=Green-B,Green-C,Green-D,Green-E,Mattapan",
"active": true,
"frequency_in_seconds": 120,
"time_ranges": [
Expand Down Expand Up @@ -59,8 +59,76 @@
"data"
],
"expects": {
"expectation": "min_length",
"min_length": 175
"expectation": "active_schedule_min_length",
"routes": ["Green-B", "Green-C", "Green-D", "Green-E", "Mattapan"],
"multiplier": 0.05
}
}
]
},
{
"name": "heavy-rail-predictions",
"url": "https://api-v3.mbta.com/predictions?filter[route]=Red,Orange,Blue",
"active": true,
"frequency_in_seconds": 120,
"time_ranges": [
{
"type": "weekly",
"day": "SUN",
"start": "05:59",
"stop": "23:59"
},
{
"type": "weekly",
"day": "MON",
"start": "05:59",
"stop": "23:59"
},
{
"type": "weekly",
"day": "TUE",
"start": "05:59",
"stop": "23:59"
},
{
"type": "weekly",
"day": "WED",
"start": "05:59",
"stop": "23:59"
},
{
"type": "weekly",
"day": "THU",
"start": "05:59",
"stop": "23:59"
},
{
"type": "weekly",
"day": "FRI",
"start": "05:59",
"stop": "23:59"
},
{
"type": "weekly",
"day": "SAT",
"start": "05:59",
"stop": "23:59"
}
],
"checks": [
{
"type": "stale",
"time_limit_in_seconds": 300
},
{
"type": "json",
"keypath": [
"data"
],
"expects": {
"expectation": "active_schedule_min_length",
"routes": ["Red", "Orange", "Blue"],
"multiplier": 0.05
}
}
]
Expand Down Expand Up @@ -130,7 +198,7 @@
},
{
"name": "commuter-rail-predictions",
"url": "https://api-v3.mbta.com/predictions?filter[route]=CR-Fairmount,CR-Fitchburg,CR-Worcester,CR-Franklin,CR-Greenbush,CR-Haverhill,CR-Kingston,CR-Lowell,CR-Middleborough,CR-Needham,CR-Newburyport,CR-Providence,CR-Foxboro&filter[stop_sequence]=3,4,5",
"url": "https://api-v3.mbta.com/predictions?filter[route]=CR-Fairmount,CR-Fitchburg,CR-Worcester,CR-Franklin,CR-Greenbush,CR-Haverhill,CR-Kingston,CR-Lowell,CR-Middleborough,CR-Needham,CR-Newburyport,CR-Providence,CR-Foxboro&filter[stop_sequence]=30,40,50",
"active": true,
"frequency_in_seconds": 120,
"time_ranges": [
Expand Down
Loading
Loading