Skip to content
Closed
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
2 changes: 1 addition & 1 deletion .credo.exs
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@
{Credo.Check.Refactor.Apply, []},
{Credo.Check.Refactor.CondStatements, []},
{Credo.Check.Refactor.CyclomaticComplexity, []},
{Credo.Check.Refactor.FunctionArity, []},
{Credo.Check.Refactor.FunctionArity, [max_arity: 9]},
{Credo.Check.Refactor.LongQuoteBlocks, []},
{Credo.Check.Refactor.MatchInCondition, []},
{Credo.Check.Refactor.MapJoin, []},
Expand Down
304 changes: 304 additions & 0 deletions lib/zexbox/auto_escalation.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,304 @@
defmodule Zexbox.AutoEscalation do
@moduledoc """
Automatic error-to-on-call escalation via Jira.

Mirrors `Opsbox::AutoEscalation`. When an error occurs, this module finds an
existing open Jira ticket by fingerprint or creates a new Bug, adds a comment on
recurrence, and always transitions new tickets to "To do" to trigger the medic/IRM
process.

## Usage

Call from a `rescue` block and pass `__STACKTRACE__` so the stack trace is
included in the ticket:

```elixir
try do
process_checkout(user, basket)
rescue
e ->
Zexbox.AutoEscalation.handle_error(
e,
"checkout",
"High",
"Purchase Ops",
__STACKTRACE__,
%{email: user.email},
%{basket_id: basket.id}
)
end
```

## Return values

- `{:ok, ticket_map}` – ticket found or created; map has `"key"`, `"id"`, `"self"`, `"url"`.
- `{:error, reason}` – ticket creation or transition failed; rescue `Zexbox.AutoEscalation.Error`.
- `{:disabled, nil}` – feature is disabled via config (no Jira calls made).

## Configuration

```elixir
config :zexbox,
jira_base_url: "https://zigroup.atlassian.net",
jira_email: System.get_env("JIRA_USER_EMAIL_ADDRESS"),
jira_api_token: System.get_env("JIRA_API_TOKEN"),
auto_escalation_enabled: true,
app_env: :production # :production → SP project; anything else → SS project
```

Disable per environment:

```elixir
# config/dev.exs
config :zexbox, auto_escalation_enabled: false
```
"""

alias Zexbox.{AutoEscalation.AdfBuilder, JiraClient}

require Logger

defmodule Error do
@moduledoc "Raised when Jira ticket creation or transition fails."
defexception [:message]
end

@project_key_sandbox "SS"
@project_key_support "SP"
# Tickets in these statuses are considered resolved; new occurrences add a comment instead.
@resolved_statuses ["Done", "No Further Action", "Ready for Support Approval"]
@transition_to "To do"
@issuetype "Bug"
@compile_env Mix.env()

@doc """
Handle an error by finding or creating a Jira ticket.

Required arguments:
- `error` – the `Exception.t()` that was rescued.
- `action` – short label (e.g. `"checkout"`); used in the fingerprint and summary.
- `priority` – Jira priority name (e.g. `"High"`).
- `zigl_team` – value for the ZIGL Team custom field.

Optional arguments (all default to `nil` or empty):
- `stacktrace` – pass `__STACKTRACE__` from the rescue block for a full trace.
- `user_context` – map rendered as a bullet list in the ticket body.
- `additional_context` – map of extra key/value pairs in the ticket body.
- `fingerprint` – override deduplication key; auto-generated as `"action::ErrorClass"` when `nil`.
- `custom_description` – string rendered above Error Details (split on `\\n\\n`).
"""
@spec handle_error(
Exception.t(),
String.t(),
String.t(),
String.t(),
Exception.stacktrace() | nil,
map(),
map(),
String.t() | nil,
String.t() | nil
) :: {:ok, map()} | {:error, term()} | {:disabled, nil}
def handle_error(
error,
action,
priority,
zigl_team,
stacktrace \\ nil,
user_context \\ %{},
additional_context \\ %{},
fingerprint \\ nil,
custom_description \\ nil
) do
if auto_escalation_enabled?() do
do_handle_error(
error,
action,
priority,
zigl_team,
stacktrace,
user_context,
additional_context,
fingerprint,
custom_description
)
else
{:disabled, nil}
end
end

@doc """
Generates the default deduplication fingerprint for an error.

## Examples

iex> Zexbox.AutoEscalation.generate_fingerprint("StandardError", "checkout")
"checkout::StandardError"
"""
@spec generate_fingerprint(String.t(), String.t()) :: String.t()
def generate_fingerprint(error_class, action), do: "#{action}::#{error_class}"

# --- Private ---

defp do_handle_error(
error,
action,
priority,
zigl_team,
stacktrace,
user_context,
additional_context,
fingerprint_override,
custom_description
) do
unless is_exception(error) do
raise ArgumentError, "Expected an Exception.t() for :error, got: #{inspect(error)}"
end

error_class = inspect(error.__struct__)
fingerprint = fingerprint_override || generate_fingerprint(error_class, action)

case find_existing_ticket(fingerprint) do
nil ->
create_jira_ticket(
error,
action,
priority,
zigl_team,
fingerprint,
user_context,
additional_context,
custom_description,
stacktrace
)

existing_ticket ->
add_comment_to_existing_ticket(
existing_ticket,
error,
action,
user_context,
additional_context,
custom_description,
stacktrace
)

{:ok, existing_ticket}
end
end

defp find_existing_ticket(fingerprint) do
project_key = resolve_project_key()
escaped = String.replace(fingerprint, "\"", "\\\"")
field_name = JiraClient.bug_fingerprint_field().name
status_list = Enum.map_join(@resolved_statuses, ", ", fn s -> "\"#{s}\"" end)
jql = "\"#{field_name}\" = \"#{escaped}\" AND status NOT IN (#{status_list})"

case JiraClient.search_latest_issues(jql, project_key) do
{:ok, []} ->
nil

{:ok, [first | _rest]} ->
first

{:error, e} ->
Logger.error(
"[Zexbox.AutoEscalation] Failed to find existing Jira ticket with fingerprint #{fingerprint}: #{inspect(e)}"
)

nil
end
end

defp create_jira_ticket(
error,
action,
priority,
zigl_team,
fingerprint,
user_context,
additional_context,
custom_description,
stacktrace
) do
project_key = resolve_project_key()
error_class = inspect(error.__struct__)

description =
AdfBuilder.build_description(
error,
user_context,
additional_context,
custom_description: custom_description,
stacktrace: stacktrace
)

custom_fields = %{
JiraClient.bug_fingerprint_field().id => fingerprint,
JiraClient.zigl_team_field().id => %{"value" => zigl_team}
}

with {:ok, result} <-
JiraClient.create_issue(
project_key,
"#{action}: #{error_class}",
description,
@issuetype,
priority,
custom_fields
),
{:ok, _resp} <- JiraClient.transition_issue(result["key"], @transition_to) do
{:ok, result}
else
{:error, e} ->
Logger.error(
"[Zexbox.AutoEscalation] Failed to create Jira ticket with fingerprint #{fingerprint} and action #{action}: #{inspect(e)}"
)

{:error, "Failed to create Jira ticket: #{inspect(e)}"}
end
end

defp add_comment_to_existing_ticket(
ticket,
error,
action,
user_context,
additional_context,
custom_description,
stacktrace
) do
issue_key = ticket["key"]

comment =
AdfBuilder.build_comment(
error,
action,
user_context,
additional_context,
custom_description: custom_description,
stacktrace: stacktrace
)

case JiraClient.add_comment(issue_key, comment) do
{:ok, _resp} ->
:ok

{:error, e} ->
Logger.error(
"[Zexbox.AutoEscalation] Failed to add comment to Jira ticket #{issue_key}: #{inspect(e)}"
)

:ok
end
end

defp resolve_project_key do
if app_env() == :production, do: @project_key_support, else: @project_key_sandbox
end

defp auto_escalation_enabled? do
Application.get_env(:zexbox, :auto_escalation_enabled, true) == true
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This environment variable will need to be added

end

defp app_env, do: Application.get_env(:zexbox, :app_env, @compile_env)
end
7 changes: 7 additions & 0 deletions mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@
"erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"},
"ex_doc": {:hex, :ex_doc, "0.35.1", "de804c590d3df2d9d5b8aec77d758b00c814b356119b3d4455e4b8a8687aecaf", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "2121c6402c8d44b05622677b761371a759143b958c6c19f6558ff64d0aed40df"},
"file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"},
"finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"},
"gun": {:hex, :gun, "2.2.0", "b8f6b7d417e277d4c2b0dc3c07dfdf892447b087f1cc1caff9c0f556b884e33d", [:make, :rebar3], [{:cowlib, ">= 2.15.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm", "76022700c64287feb4df93a1795cff6741b83fb37415c40c34c38d2a4645261a"},
"hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"influxql": {:hex, :influxql, "0.2.1", "71bfd5c0d81bf870f239baf3357bf5226b44fce16e1b9399ba1368203ca71245", [:mix], [], "hexpm", "75faf04960d6830ca0827869eaac1ba092655041c5e96deb2a588bafb601205c"},
"instream": {:hex, :instream, "2.2.1", "8f27352b0490f3d43387d9dfb926e6235570ea8a52b3675347c98efd7863a86d", [:mix], [{:hackney, "~> 1.1", [hex: :hackney, repo: "hexpm", optional: false]}, {:influxql, "~> 0.2.0", [hex: :influxql, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: false]}, {:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: false]}], "hexpm", "e20c7cc24991fdd228fa93dc080ee7b9683f4c1509b3b718fdd385128d018c2a"},
Expand All @@ -25,15 +27,20 @@
"makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"},
"meck": {:hex, :meck, "0.9.2", "85ccbab053f1db86c7ca240e9fc718170ee5bda03810a6292b5306bf31bae5f5", [:rebar3], [], "hexpm", "81344f561357dc40a8344afa53767c32669153355b626ea9fcbc8da6b3045826"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"},
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
"mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"},
"mock": {:hex, :mock, "0.3.9", "10e44ad1f5962480c5c9b9fa779c6c63de9bd31997c8e04a853ec990a9d841af", [:mix], [{:meck, "~> 0.9.2", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "9e1b244c4ca2551bb17bb8415eed89e40ee1308e0fbaed0a4fdfe3ec8a4adbd3"},
"nimble_csv": {:hex, :nimble_csv, "1.3.0", "b7f998dc62b222bce9596e46f028c7a5af04cb5dde6df2ea197c583227c54971", [:mix], [], "hexpm", "41ccdc18f7c8f8bb06e84164fc51635321e80d5a3b450761c4997d620925d619"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"opentelemetry_api": {:hex, :opentelemetry_api, "1.5.0", "1a676f3e3340cab81c763e939a42e11a70c22863f645aa06aafefc689b5550cf", [:mix, :rebar3], [], "hexpm", "f53ec8a1337ae4a487d43ac89da4bd3a3c99ddf576655d071deed8b56a2d5dda"},
"parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"},
"poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"},
"quickrand": {:hex, :quickrand, "2.0.7", "d2bd76676a446e6a058d678444b7fda1387b813710d1af6d6e29bb92186c8820", [:rebar3], [], "hexpm", "b8acbf89a224bc217c3070ca8bebc6eb236dbe7f9767993b274084ea044d35f0"},
"req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
"shotgun": {:hex, :shotgun, "1.2.1", "a720063b49a763a97b245cc1ab6ee34e0e50d1ef61858e080db8e3b0dcd31af2", [:rebar3], [{:gun, "2.2.0", [hex: :gun, repo: "hexpm", optional: false]}], "hexpm", "a5ed7a1ff851419a70e292c4e2649c4d2c633141eb9a3432a4896c72b6d3f212"},
"sobelow": {:hex, :sobelow, "0.14.0", "dd82aae8f72503f924fe9dd97ffe4ca694d2f17ec463dcfd365987c9752af6ee", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "7ecf91e298acfd9b24f5d761f19e8f6e6ac585b9387fb6301023f1f2cd5eed5f"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
Expand Down
Loading