Skip to content

agoodway/goodissues_reporter

Repository files navigation

GoodIssuesReporter

Elixir library that reports errors, OpenTelemetry traces, and system resource breaches to the GoodIssues API. Integrates with error_tracker for error reporting, auto-instruments Phoenix, LiveView, and Ecto via the OTel SDK, and monitors BEAM/OS metrics for threshold breaches.

Features

  • Error reporting — Listens for error_tracker telemetry events and reports errors to GoodIssues with fingerprinting, context, breadcrumbs, and stacktraces
  • OpenTelemetry integration — Auto-instruments Phoenix, LiveView, and Ecto via the OTel SDK, exporting spans to GoodIssues via OTLP
  • Trace correlation — Injects trace_id from active OTel spans into error and incident payloads
  • System monitor — Polls BEAM-native and OS-level metrics, reporting threshold breaches as incidents to GoodIssues with server-side deduplication and auto-resolve
  • Network scanner — Periodically scans the local host for unexpected open TCP ports using deny/allow list strategies, reporting incidents with auto-resolve when ports close
  • Async by default — API calls are non-blocking via Task.Supervisor, with a synchronous mode for testing

Installation

Add good_issues_reporter to your dependencies in mix.exs:

def deps do
  [
    {:good_issues_reporter, github: "agoodway/goodissues_reporter"}
  ]
end

Quick Start

1. Configure

# config/runtime.exs
config :good_issues_reporter,
  api_url: System.fetch_env!("GOODISSUES_API_URL"),
  api_key: System.fetch_env!("GOODISSUES_API_KEY"),
  project_id: System.fetch_env!("GOODISSUES_PROJECT_ID")

2. Add to your supervision tree

# lib/my_app/application.ex
def start(_type, _args) do
  children = [
    MyApp.Repo,
    MyAppWeb.Endpoint,
    {GoodIssuesReporter, []}
  ]

  Supervisor.start_link(children, strategy: :one_for_one)
end

3. Disable in dev/test

# config/dev.exs
config :good_issues_reporter, enabled: false

# config/test.exs
config :good_issues_reporter, enabled: false

That's it. Errors tracked by error_tracker will now be reported to GoodIssues automatically.

Note: This library is compatible with error_tracker >= 0.5. For the most reliable telemetry integration (guaranteed :occurrence data on new errors, proper stacktraces, etc.), we recommend error_tracker ~> 0.9.0 or later.

4. Mount the health endpoint (optional)

Add a health-check route for the reporter in your Phoenix router:

# lib/my_app_web/router.ex
forward "/gi", GoodIssuesReporter.HealthRouter

GET /gi/health returns JSON indicating whether the reporter is locally configured and enabled:

  • 200 {"status": "ok", "details": {...}} — reporter is enabled and all required config is present
  • 503 {"status": "error", "details": {...}} — reporter is disabled or required config is missing

The details object includes boolean flags (enabled, configured, api_url, api_key, project_id) without exposing raw config values.

This endpoint validates local configuration only — it does not contact the GoodIssues API or perform any network calls. It reflects reporter readiness, not API availability or whole-app health.

Caution

Security: The health route exposes whether the reporter is configured and enabled. To avoid leaking deployment details:

  • Mount it behind internal routing or existing auth controls — do not expose it on public paths.
  • Apply proxy-level rate limiting to prevent enumeration or abuse.
  • Review your network and authentication posture before deploying.

5. Capture browser JavaScript errors (optional)

Mount the browser error endpoint in your Phoenix router. The endpoint is designed for same-origin browser beacons and exposes POST /report below the forwarded path:

# lib/my_app_web/router.ex
forward "/gi/errors", GoodIssuesReporter.BrowserErrorRouter

Serve the package-owned reporter script from priv/static assets:

# lib/my_app_web/endpoint.ex
plug Plug.Static,
  at: "/good_issues_reporter",
  from: {:good_issues_reporter, "priv/static"},
  gzip: false,
  only: ~w(js)

Then load and initialize the script in your frontend. For Phoenix layouts, put this near the end of your root layout body so the reporter is available before most application JavaScript runs:

<script src="/good_issues_reporter/js/good_issues_reporter/reporter.js"></script>
<script>
  window.GoodIssuesReporter.init({
    endpoint: "/gi/errors",
    context: {
      release: "2026.05.25",
      user_id: "<%= @current_user && @current_user.id %>"
    }
  });
</script>

For Phoenix applications that expose JavaScript globals from assigns, encode context explicitly instead of interpolating raw values:

<script src="/good_issues_reporter/js/good_issues_reporter/reporter.js"></script>
<script>
  window.GoodIssuesReporter.init({
    endpoint: "/gi/errors",
    context: <%= Jason.encode!(%{
      release: Application.spec(:my_app, :vsn) |> to_string(),
      user_id: @current_user && @current_user.id,
      account_id: assigns[:current_account] && @current_account.id
    }) |> Phoenix.HTML.raw() %>
  });
</script>

For bundler-based frontends, load the reporter script from the static path before initializing it from your app entry point:

<script src="/good_issues_reporter/js/good_issues_reporter/reporter.js"></script>
<script type="module" src="/assets/app.js"></script>
// assets/js/app.js
window.GoodIssuesReporter.init({
  endpoint: "/gi/errors",
  context: {
    release: window.APP_RELEASE,
    user_id: window.CURRENT_USER_ID
  }
});

Initialize the reporter once per page load. Repeated init calls update endpoint/context options but do not install duplicate global handlers.

The reporter installs guarded window.error and window.unhandledrejection handlers and also supports manual capture:

window.GoodIssuesReporter.capture(new Error("Checkout failed"));
window.GoodIssuesReporter.capture({ message: "Custom browser failure", type: "manual" });

Manual captures may include safe context fields when the global context is not enough:

try {
  await submitCheckout();
} catch (error) {
  window.GoodIssuesReporter.capture({
    type: "checkout_failure",
    message: error.message,
    stack: error.stack,
    context: {
      step: "payment",
      cart_size: cart.items.length
    }
  });

  throw error;
}

Frontend payload fields supported by the endpoint:

Field Description
type Browser event type, such as error, unhandledrejection, or a custom manual type
message Error message; required for forwarding
stack Stack trace string or list of stack lines
source, line, col Browser source URL and location fields
url, referrer Page URL and referrer; the reporter fills these automatically
event_id, timestamp Client-generated event metadata; the reporter fills these automatically
user, session, release, context Optional host-provided context; keep these values small and non-sensitive

Operational notes:

  • Browser reporting is intended for same-origin browser clients. The endpoint requires browser Fetch Metadata and accepts only Sec-Fetch-Site: same-origin, same-site, or none.
  • Requests missing Sec-Fetch-Site, explicit cross-site requests, and non-JSON content types are ignored with a beacon-safe 200 response and are not forwarded.
  • Browser error reports always dispatch asynchronously through GoodIssuesReporter.TaskSupervisor with bounded in-flight admission control. If the supervisor is unavailable or the browser dispatch queue is saturated, reports are dropped and the endpoint still returns 200.
  • This package does not provide CORS handling or origin allowlist configuration for cross-origin ingestion.
  • The endpoint is unauthenticated so browser beacons can reach it; use infrastructure rate limiting for noisy or abusive clients.
  • Browser messages, stacks, URLs, and context may contain PII. The server caps fields, strips URL query strings/fragments and URL credentials, and redacts configured sensitive context keys, but host apps should avoid sending sensitive browser context.
  • Source maps are not uploaded or processed by this package. Configure your own release/source map workflow if you need de-obfuscated browser stack traces.
  • Cross-origin script failures may report only Script error. unless the script and response headers are configured for browser stack access.

Configuration Reference

Required

Option Description
api_url Base URL of your GoodIssues instance (e.g. "https://goodissues.dev")
api_key API key for Bearer token authentication
project_id UUID of the GoodIssues project to report to

Optional

Option Default Description
enabled true Set false to disable all reporting
async true Set false for synchronous server-side reporting API calls (useful in tests)

The async option controls server-side reporting paths. Browser error reports always dispatch asynchronously through GoodIssuesReporter.TaskSupervisor, even when async: false is configured.

OpenTelemetry Options

Configure under the :otel key. OTel is disabled by default and requires a service_name matching the project's otel_service_name on GoodIssues.

config :good_issues_reporter,
  otel: [
    enabled: true,
    service_name: "my-app",
    phoenix: true,
    liveview: true,
    ecto_repo_prefix: [:my_app, :repo]
  ]
Option Default Description
enabled false Enable OTel auto-instrumentation and trace_id injection
service_name nil Must match project.otel_service_name on GoodIssues
phoenix true Attach Phoenix instrumentation (set false for non-Phoenix apps)
liveview true Attach LiveView instrumentation (set false if not using LiveView)
ecto_repo_prefix nil Telemetry prefix for Ecto repo (e.g. [:my_app, :repo]). Ecto instrumentation is skipped when nil.

Important: This library attaches OTel auto-instrumentation and injects trace_id into error/incident payloads, but does not configure the OTel SDK. The host app must configure the :opentelemetry application directly:

# config/runtime.exs
config :opentelemetry,
  resource: [{"service.name", "my-app"}],
  traces_exporter: {:otlp, [
    protocol: :http_protobuf,
    endpoints: [System.fetch_env!("GOODISSUES_API_URL") <> "/api/v1/otlp/traces"],
    headers: [{"authorization", "Bearer " <> System.fetch_env!("GOODISSUES_API_KEY")}]
  ]}

Setting enabled: false globally (via config :good_issues_reporter, enabled: false) disables all reporting including OTel auto-instrumentation.

Context Sanitization

Error payloads (including context, breadcrumbs, and stacktrace from error_tracker occurrences) are sent to the GoodIssues API. This library performs built-in redaction of common sensitive keys (password, secret, token, api_key, authorization, cookie, etc.) and supports additional filtering via the :filter_parameters configuration. However, the host application is still responsible for sanitizing sensitive data before it reaches error_tracker — use error_tracker context filters or a custom ErrorTracker.Integrations.Plug for strong guarantees.

System Monitor Options

The system monitor polls BEAM and OS-level metrics on a configurable interval and reports threshold breaches as incidents to GoodIssues. Deduplication is handled server-side via fingerprint matching. When a metric recovers below its threshold, the incident is automatically resolved.

Configure under the :system_monitor key:

config :good_issues_reporter,
  system_monitor: [
    enabled: true,
    poll_interval_ms: 30_000,
    severity: "warning",
    reopen_window_hours: 24,

    # BEAM-native metrics (set to nil to disable)
    memory_total_mb: 512,
    memory_processes_mb: 256,
    process_count: 50_000,
    atom_count: 500_000,
    port_count: 10_000,
    run_queue_length: 20,

    # OS-level metrics via :os_mon (set to nil to disable)
    cpu_percent: 90,
    disk_percent: 85,
    system_memory_percent: 90,
    swap_percent: 80
  ]
Option Default Description
enabled false Enable system resource monitoring
poll_interval_ms 30_000 How often to check metrics (min: 10,000)
severity "warning" Incident severity: "info", "warning", or "critical"
reopen_window_hours 24 How long after resolution a new occurrence reopens the incident

BEAM Metrics

Metric Config Key Source Unit
Total BEAM memory memory_total_mb :erlang.memory(:total) MB
Process memory memory_processes_mb :erlang.memory(:processes) MB
Process count process_count :erlang.system_info(:process_count) count
Atom count atom_count :erlang.system_info(:atom_count) count
Port count port_count :erlang.system_info(:port_count) count
Run queue length run_queue_length :erlang.statistics(:run_queue) count

OS-Level Metrics (requires :os_mon)

Metric Config Key Source Unit
CPU utilization cpu_percent :cpu_sup.util/0 percent (0-100)
Disk usage disk_percent :disksup.get_disk_data/0 percent per partition
System memory system_memory_percent :memsup.get_system_memory_data/0 percent
Swap usage swap_percent :memsup.get_system_memory_data/0 percent

OS-level metrics require the :os_mon application. The system monitor will attempt to start it automatically when needed. On platforms where :os_mon is unavailable, those metrics are skipped and retried each poll cycle.

Set any metric to nil (or omit it) to disable monitoring for that metric.

Network Scanner Options

The network scanner periodically scans the local host for unexpected open TCP ports using :gen_tcp.connect/4. It supports two strategies:

  • :deny — configured ports should NOT be open. Any deny-listed port found open triggers an incident.
  • :allow — configured ports are the only ones permitted. Any other open port triggers an incident.

When a previously-violating port closes, the incident is automatically resolved.

config :good_issues_reporter,
  network_scanner: [
    enabled: true,
    scan_interval_ms: 3_600_000,
    host: "127.0.0.1",
    strategy: :deny,
    ports: [3306, 5432, 6379, 11211, 9200, 27017],
    connect_timeout_ms: 1_000,
    severity: "warning",
    source: "network_scanner",
    reopen_window_hours: 24
  ]
Option Default Description
enabled false Enable network port scanning
scan_interval_ms 3_600_000 Scan interval (min: 60,000)
host "127.0.0.1" Host to scan (self-scan only)
strategy :deny :deny or :allow
ports [3306, 5432, ...] Port list for the chosen strategy
connect_timeout_ms 1_000 TCP connect timeout per port
severity "warning" Incident severity
source "network_scanner" Incident source label
reopen_window_hours 24 Incident reopen window

Note: The network scanner performs TCP connections to localhost only. In :allow mode, it scans a fixed set of ~70 well-known service ports to discover unexpected listeners.

Heartbeat Monitor Options

Heartbeat monitors periodically check the health of supervised processes and report status to GoodIssues heartbeats. Each monitor maps to a heartbeat you've provisioned in GoodIssues, using the returned ping_token as the heartbeat_token.

Configure under the :heartbeat_monitors key as a list of keyword lists:

config :good_issues_reporter,
  heartbeat_monitors: [
    # Monitor the built-in SystemMonitor via GenServer.call(:health)
    [
      enabled: true,
      name: :system_monitor,
      target: GoodIssuesReporter.SystemMonitor,
      heartbeat_token: System.fetch_env!("GOODISSUES_SYSTEM_MONITOR_PING_TOKEN"),
      check: {:call, :health},
      interval_ms: 60_000,
      timeout_ms: 2_000
    ],
    # Monitor a host application GenServer
    [
      enabled: true,
      name: :my_worker,
      target: MyApp.Worker,
      heartbeat_token: System.fetch_env!("GOODISSUES_WORKER_PING_TOKEN"),
      check: :process_alive,
      metadata: %{env: "production"}
    ]
  ]
Option Default Description
enabled false Enable this monitor (monitors are opt-in)
name required Monitor name for logging and ping payloads
target required Process to check — atom name, PID, {:global, term}, or {:via, module, term}
heartbeat_token required Ping token from a provisioned GoodIssues heartbeat
check required Health check strategy (see below)
interval_ms 30_000 Check interval (min: 30,000)
timeout_ms 1_000 Timeout for call/MFA checks
metadata %{} Extra metadata merged into ping payloads

Health Check Strategies

Strategy Description Success Condition
:process_alive Resolves the target and checks Process.alive?/1 Process exists and is alive
{:call, message} Calls the target with GenServer.call/3 Reply is :ok or {:ok, _}
{:mfa, module, function, args} Calls apply(module, function, args) in a supervised task Returns :ok, true, or {:ok, _}

For {:call, :health}, the target GenServer should implement:

def handle_call(:health, _from, state) do
  {:reply, :ok, state}
end

The built-in SystemMonitor already implements this contract when enabled with at least one active or pending metric.

Note: Heartbeat monitors only start when global enabled is true. The reporter does not create heartbeat resources — you must provision heartbeats in GoodIssues and use the returned ping_token as the heartbeat_token value.

Full Configuration Example

# config/runtime.exs
config :good_issues_reporter,
  api_url: System.fetch_env!("GOODISSUES_API_URL"),
  api_key: System.fetch_env!("GOODISSUES_API_KEY"),
  project_id: System.fetch_env!("GOODISSUES_PROJECT_ID"),
  enabled: true,
  async: true

config :good_issues_reporter,
  otel: [
    enabled: true,
    service_name: "my-app",
    ecto_repo_prefix: [:my_app, :repo]
  ]

# OTel SDK config — host app responsibility
config :opentelemetry,
  resource: [{"service.name", "my-app"}],
  traces_exporter: {:otlp, [
    protocol: :http_protobuf,
    endpoints: [System.fetch_env!("GOODISSUES_API_URL") <> "/api/v1/otlp/traces"],
    headers: [{"authorization", "Bearer " <> System.fetch_env!("GOODISSUES_API_KEY")}]
  ]}

config :good_issues_reporter,
  system_monitor: [
    enabled: true,
    poll_interval_ms: 30_000,
    severity: "warning",
    reopen_window_hours: 24,
    memory_total_mb: 512,
    process_count: 100_000,
    cpu_percent: 90,
    disk_percent: 85
  ]

config :good_issues_reporter,
  network_scanner: [
    enabled: true,
    strategy: :deny,
    ports: [3306, 5432, 6379]
  ]

config :good_issues_reporter,
  heartbeat_monitors: [
    [
      enabled: true,
      name: :system_monitor,
      target: GoodIssuesReporter.SystemMonitor,
      heartbeat_token: System.fetch_env!("GOODISSUES_SYSTEM_MONITOR_PING_TOKEN"),
      check: {:call, :health}
    ]
  ]

Architecture

GoodIssuesReporter (Supervisor)
├── Task.Supervisor               # Async API call execution
├── TelemetryHandler              # error_tracker event listener + trace_id capture
├── OTel auto-instrumentation     # Phoenix, LiveView, Ecto (when enabled)
├── SystemMonitor                 # BEAM/OS metric polling → incidents
├── HeartbeatMonitor.Watcher(s)   # Per-monitor health check + ping
└── NetworkScanner                # Local TCP port scanning → incidents

Error Reporting Flow

  1. error_tracker emits [:error_tracker, :error, :new] telemetry event
  2. TelemetryHandler receives the event and passes metadata to Client.create_error/2
  3. Mapper.to_error/2 transforms error-tracker metadata into the GoodIssues API payload
  4. Client delegates to GoodissuesEx.create_error/2 (generated API client)

OpenTelemetry Flow

  1. The host app configures the :opentelemetry SDK in config/runtime.exs
  2. When OTel is enabled, auto-instrumentation attaches to Phoenix, LiveView, and Ecto telemetry
  3. The OTel SDK collects spans and exports them via OTLP HTTP to the configured endpoint
  4. trace_id from active spans is injected into error and incident payloads for correlation

System Monitor Flow

  1. SystemMonitor GenServer polls metrics on a configurable interval
  2. When a metric exceeds its configured threshold, an incident is created via IncidentReporter.create_incident/2
  3. Server-side fingerprint deduplication prevents duplicate incidents — repeated breaches add occurrences
  4. When a metric recovers below threshold, the incident is automatically resolved via IncidentReporter.resolve_incident/2

Network Scanner Flow

  1. NetworkScanner GenServer scans configured ports on a configurable interval
  2. For deny strategy: any deny-listed port found open triggers an incident
  3. For allow strategy: any open port not in the allow list triggers an incident
  4. When a previously-violating port closes, the incident is automatically resolved

Error Payload Mapping

Error-Tracker Field API Field Notes
error.kind kind Direct pass-through
error.reason reason Direct pass-through
error.source_line source_line Defaults to "-" if nil
error.source_function source_function Defaults to "-" if nil
error.fingerprint fingerprint Used for occurrence aggregation
config project_id From configuration
occurrence.context context Map (max 50 keys)
occurrence.breadcrumbs breadcrumbs Array of strings (max 100)
occurrence.stacktrace stacktrace %{lines: [...]} structure (max 100 lines)
OTel span context trace_id 32-char hex, omitted when no span active

Context values are filtered before sending (common sensitive keys are redacted recursively; additional keys can be configured via :filter_parameters). See the Context Sanitization section for details and host-app responsibilities.

Breaking Changes

cooldown_ms removed from SystemMonitor config

The cooldown_ms option has been removed from the :system_monitor configuration. Deduplication of threshold breach reports is now handled server-side by the GoodIssues Incident API via fingerprint matching.

Migration: Remove any cooldown_ms entries from your system_monitor config. Use severity and reopen_window_hours to control incident behavior instead.

Dependencies

This library depends on goodissues_ex for the generated GoodIssues API client. It requires support for the following operations:

  • Error reporting (create_error)
  • Incident creation and resolution (create_incident / resolve_incident)
  • Heartbeat pings (ping_heartbeat_ping success + failure variants)

The version currently pinned in mix.exs is known to be compatible:

{:goodissues_ex,
 github: "agoodway/goodissues_ex", ref: "d88dec847ad4caebb2b4406ea112cf60c14d34ae"}

When depending on good_issues_reporter, ensure any goodissues_ex override matches or exceeds this revision.

License

MIT

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors