if Code.ensure_loaded?(Absinthe) do
defmodule PromEx.Plugins.Absinthe do
@moduledoc """
This plugin captures metrics emitted by Absinthe. Specifically, it captures timings and metrics
around execution times, query complexity, and subscription timings. In order to get complexity
metrics you'll need to make sure that you have `:analyze_complexity` enabled in
[Absinthe.Plug](https://hexdocs.pm/absinthe_plug/Absinthe.Plug.html#t:opts/0). This plugin can
generate a large amount of Prometheus series, so it is suggested that you use the
`ignored_entrypoints` and `only_entrypoints` (TODO: coming soon) options to prune down the
resulting metrics if needed.
This plugin supports the following options:
- `ignored_entrypoints`: This option is OPTIONAL and is used to filter out Absinthe GraphQL
schema entrypoints that you do not want to track metrics for. For example, if you don't want
metrics on the `:__schema` entrypoint (used for GraphQL schema introspection), you would set
a value of `[:__schema]`. This is applicable to queries, mutations, and subscriptions.
- `metric_prefix`: This option is OPTIONAL and is used to override the default metric prefix of
`[otp_app, :prom_ex, :absinthe]`. If this changes you will also want to set `absinthe_metric_prefix`
in your `dashboard_assigns` to the snakecase version of your prefix, the default
`absinthe_metric_prefix` is `{otp_app}_prom_ex_absinthe`.
- `duration_unit`: This is an OPTIONAL option and is a `Telemetry.Metrics.time_unit()`. It can be one of:
`:second | :millisecond | :microsecond | :nanosecond`. It is `:millisecond` by default.
This plugin exposes the following metric groups:
- `:absinthe_execute_event_metrics`
- `:absinthe_subscription_event_metrics`
To use plugin in your application, add the following to your PromEx module:
```
defmodule WebApp.PromEx do
use PromEx, otp_app: :web_app
@impl true
def plugins do
[
...
{PromEx.Plugins.Absinthe, ignored_entrypoints: [:__schema]}
]
end
@impl true
def dashboards do
[
...
{:prom_ex, "absinthe.json"}
]
end
end
```
"""
use PromEx.Plugin
alias PromEx.Utils
# @operation_execute_start_event [:absinthe, :execute, :operation, :start]
@operation_execute_stop_event [:absinthe, :execute, :operation, :stop]
# @subscription_publish_start_event [:absinthe, :subscription, :publish, :start]
@subscription_publish_stop_event [:absinthe, :subscription, :publish, :stop]
# @resolve_field_start_event [:absinthe, :resolve, :field, :start]
# @resolve_field_stop_event [:absinthe, :resolve, :field, :stop]
# @middleware_batch_start_event [:absinthe, :middleware, :batch, :start]
# @middleware_batch_stop_event [:absinthe, :middleware, :batch, :stop]
@impl true
def event_metrics(opts) do
otp_app = Keyword.fetch!(opts, :otp_app)
metric_prefix = Keyword.get(opts, :metric_prefix, PromEx.metric_prefix(otp_app, :absinthe))
# Event metrics definitions
[
operation_execute_events(metric_prefix, opts),
subscription_publish_events(metric_prefix, opts)
]
end
defp subscription_publish_events(metric_prefix, opts) do
# Fetch user options
ignored_entrypoints =
opts
|> Keyword.get(:ignored_entrypoints, [])
|> MapSet.new()
duration_unit = Keyword.get(opts, :duration_unit, :millisecond)
duration_unit_plural = Utils.make_plural_atom(duration_unit)
event_tags = [:schema, :operation_type, :entrypoint]
Event.build(
:absinthe_subscription_event_metrics,
[
# Capture GraphQL request duration information
distribution(
metric_prefix ++ [:subscription, :duration, duration_unit_plural],
event_name: @subscription_publish_stop_event,
measurement: :duration,
description: "The time it takes for the Absinthe to publish subscription data.",
reporter_options: [
buckets: [10, 100, 500, 1_000, 5_000, 10_000, 30_000]
],
tag_values: &subscription_stop_tag_values/1,
tags: event_tags,
unit: {:native, duration_unit},
drop: entrypoint_in_ignore_set?(ignored_entrypoints)
)
]
)
end
defp operation_execute_events(metric_prefix, opts) do
# Fetch user options
ignored_entrypoints =
opts
|> Keyword.get(:ignored_entrypoints, [])
|> MapSet.new()
duration_unit = Keyword.get(opts, :duration_unit, :millisecond)
duration_unit_plural = Utils.make_plural_atom(duration_unit)
event_tags = [:schema, :operation_type, :entrypoint]
Event.build(
:absinthe_execute_event_metrics,
[
# Capture GraphQL request duration information
distribution(
metric_prefix ++ [:execute, :duration, duration_unit_plural],
event_name: @operation_execute_stop_event,
measurement: :duration,
description: "The time it takes for the Absinthe to complete the operation.",
reporter_options: [
buckets: [10, 100, 500, 1_000, 5_000, 10_000, 30_000]
],
tag_values: &operation_execute_stop_tag_values/1,
tags: event_tags,
unit: {:native, duration_unit},
drop: entrypoint_in_ignore_set?(ignored_entrypoints)
),
# Capture GraphQL request complexity
distribution(
metric_prefix ++ [:execute, :complexity, :size],
event_name: @operation_execute_stop_event,
measurement: fn _measurements, metadata ->
current_operation = Absinthe.Blueprint.current_operation(metadata.blueprint)
current_operation.complexity
end,
description: "The estimated complexity for a given Absinthe operation.",
reporter_options: [
buckets: [10, 50, 100, 200, 500]
],
tag_values: &operation_execute_stop_tag_values/1,
tags: event_tags,
drop: fn metadata ->
metadata.blueprint
|> Absinthe.Blueprint.current_operation()
|> case do
nil ->
true
current_operation ->
entrypoint = entrypoint_from_current_operation(current_operation)
MapSet.member?(ignored_entrypoints, entrypoint) or is_nil(current_operation.complexity)
end
end
),
# Count Absinthe executions that resulted in errors
counter(
metric_prefix ++ [:execute, :invalid, :request, :count],
event_name: @operation_execute_stop_event,
tag_values: &operation_execute_stop_tag_values/1,
tags: [:schema],
keep: fn metadata ->
metadata.blueprint.execution.validation_errors != []
end
)
]
)
end
defp entrypoint_in_ignore_set?(ignored_entrypoints) do
fn metadata ->
metadata.blueprint
|> Absinthe.Blueprint.current_operation()
|> case do
nil ->
true
current_operation ->
entrypoint = entrypoint_from_current_operation(current_operation)
MapSet.member?(ignored_entrypoints, entrypoint)
end
end
end
defp subscription_stop_tag_values(metadata) do
metadata.blueprint
|> Absinthe.Blueprint.current_operation()
|> case do
nil ->
%{
schema: :unknown,
operation_type: :unknown,
entrypoint: :unknown
}
current_operation ->
%{
schema: normalize_module_name(current_operation.schema_node.definition),
operation_type: Map.get(current_operation, :type, :unknown),
entrypoint: entrypoint_from_current_operation(current_operation)
}
end
end
defp operation_execute_stop_tag_values(metadata) do
metadata.blueprint
|> Absinthe.Blueprint.current_operation()
|> case do
nil ->
schema =
metadata.options
|> Keyword.get(:schema, :unknown)
|> normalize_module_name()
%{
schema: schema,
operation_type: :unknown,
entrypoint: :unknown
}
current_operation ->
%{
schema: normalize_module_name(current_operation.schema_node.definition),
operation_type: Map.get(current_operation, :type, :unknown),
entrypoint: entrypoint_from_current_operation(current_operation)
}
end
end
defp entrypoint_from_current_operation(current_operation) do
current_operation.selections
|> List.first()
|> Map.get(:schema_node)
|> case do
nil ->
:invalid_entrypoint
valid_entrypoint ->
Map.get(valid_entrypoint, :identifier)
end
end
defp normalize_module_name(name) when is_atom(name) do
name
|> Atom.to_string()
|> String.trim_leading("Elixir.")
end
defp normalize_module_name(name), do: name
end
else
defmodule PromEx.Plugins.Absinthe do
@moduledoc false
use PromEx.Plugin
@impl true
def event_metrics(_opts) do
PromEx.Plugin.no_dep_raise(__MODULE__, "Absinthe")
end
end
end