Skip to content

feat: introduce errata for structured replay errors - #2265

Open
jvoegele wants to merge 2 commits into
mainfrom
chore/introduce-errata
Open

jvoegele wants to merge 2 commits into
mainfrom
chore/introduce-errata

Conversation

@jvoegele

Copy link
Copy Markdown
Contributor

First step towards using Errata for error handling. It covers one flow end to end, broadcast replay, so the pattern is established and reviewable before committing to it.

Realtime.Messages.replay/5 now returns ReplayRejected (bad params, public channel) or ReplayFailed (query failure, wrapping whatever the tenant Repo returned) instead of bare atoms. Each error records the tenant, topic, since, and limit that were requested, plus where it was created. The channel join reports any Errata error through a new Logging.log_error/2, which logs it under the error's code and attaches the type, reason, context, cause and origin as Logger metadata. The metadata is nested under an error key because the Logflare backend overwrites a top-level context.

Client-facing reasons and the "UnableToReplayMessages" error code are unchanged, so dashboards, alerts and existing tests are unaffected. One behavior change: a query that returns a Postgrex error, rather than raising, was previously reported as "UnknownErrorOnChannel" and now reaches the client as a replay failure.

What kind of change does this PR introduce?

A first, deliberately small step towards structured error handling with Errata. This PR covers one flow end to end: broadcast replay on channel join.

What is the current behavior?

Replay failures come out of Realtime.Messages.replay/5 as bare atoms. The channel join maps each one to a fixed string in its own clause, and the resulting log event knows only the tenant:

[error] UnableToReplayMessages: Realtime was unable to replay messages
%{
  project: "hglmbfxqfbeoqjeyubbm",
  external_id: "hglmbfxqfbeoqjeyubbm",
  error_code: "UnableToReplayMessages"
}

Which topic, which replay window, and which line produced the error are not recorded. A query that returns a Postgrex error, rather than raising, falls through to UnknownErrorOnChannel.

What is the new behavior?

replay/5 returns one of two Errata errors: ReplayRejected for bad params or a public channel, and ReplayFailed for a query failure, wrapping whatever the Repo returned. Both carry the requested tenant, topic, since and limit, and the point where they were created.

The join handles any Errata error with a single clause. A new Logging.log_error/2 logs it under the error's code and attaches the rest as Logger metadata, nested under error because the Logflare backend overwrites a top-level context. Same event as above:

[error] UnableToReplayMessages: Realtime was unable to replay messages
%{
  error_code: "UnableToReplayMessages",
  project: "hglmbfxqfbeoqjeyubbm",
  external_id: "hglmbfxqfbeoqjeyubbm",
  error: %{
    error_type: "Realtime.Messages.ReplayFailed",
    reason: nil,
    context: %{limit: 10, since: 1758540000000, tenant_id: "hglmbfxqfbeoqjeyubbm", topic: "room:lobby"},
    cause: :postgrex_exception,
    env: %{
      module: "Realtime.Messages",
      function: "Realtime.Messages.replay/5",
      file: "lib/realtime/messages.ex",
      line: 94
    }
  }
}

Client-facing reasons and the UnableToReplayMessages code are unchanged, so dashboards, alerts and the existing channel tests are unaffected. The one behaviour change is that a returned Postgrex error is now reported as a replay failure instead of an unknown error.

Additional context

Replay was picked because it is self-contained: one origin, one boundary, and it crosses the RPC hop, which shows the error struct travelling between nodes intact.

The cause above is a bare atom because Realtime.Tenants.Repo rescues the real exception and returns :postgrex_exception. Having Repo return the exception itself is the natural follow-up, as is the HTTP side, where the fallback controller can render straight from Errata.http_status/1.

Needs errata 1.9.2: earlier versions defined a global Jason.Encoder for Tuple that clashed with the one in Realtime.Logs.

Disclosure: I'm the author of Errata.

First step towards using Errata for error handling. It covers one flow
end to end, broadcast replay, so the pattern is established and
reviewable before committing to it.

Realtime.Messages.replay/5 now returns ReplayRejected (bad params,
public channel) or ReplayFailed (query failure, wrapping whatever the
tenant Repo returned) instead of bare atoms. Each error records the
tenant, topic, since, and limit that were requested, plus where it was
created. The channel join reports any Errata error through a new
Logging.log_error/2, which logs it under the error's code and attaches
the type, reason, context, cause and origin as Logger metadata. The
metadata is nested under an `error` key because the Logflare backend
overwrites a top-level `context`.

Client-facing reasons and the UnableToReplayMessages error code are
unchanged, so dashboards, alerts and existing tests are unaffected.
One behavior change: a query that returns a Postgrex error, rather
than raising, was previously reported as UnknownErrorOnChannel and now
reaches the client as a replay failure.
@jvoegele
jvoegele requested a review from a team September 22, 2026 21:28
@github-actions

Copy link
Copy Markdown
Contributor

CRAP Score Report

Summary: files=239 functions=1316 scored=1316 worst_score=15944.76

** (Mix) CRAP threshold failed: max_score=30.00
High scores: 29
  lib/extensions/postgres_cdc_rls/subscription_manager.ex Extensions.PostgresCdcRls.SubscriptionManager.handle_info/2 score=37.16
  lib/realtime/adapters/postgres/oid_database.ex Realtime.Adapters.Postgres.OidDatabase.name_for_type_id/1 score=15944.76
  lib/realtime/application.ex Realtime.Application.setup_region_mapping/0 score=47.11
  lib/realtime/nodes.ex Realtime.Nodes.default_region_mapping/1 score=157.20
  lib/realtime/operations.ex Realtime.Operations.rebalance/0 score=42.00
  lib/realtime/operations.ex Realtime.Operations.kill_connections_to_tenant_id/2 score=90.00
  lib/realtime/tenants/connect.ex Realtime.Tenants.Connect.handle_info/2 score=40.25
  lib/realtime_web/channels/realtime_channel.ex RealtimeWeb.RealtimeChannel.handle_info/2 score=51.93
  lib/realtime_web/channels/realtime_channel.ex RealtimeWeb.RealtimeChannel.handle_in/3 score=62.70
  lib/realtime_web/dashboard/feature_flags.ex RealtimeWeb.Dashboard.FeatureFlags.handle_event/3 score=112.70
  lib/realtime_web/dashboard/node_info.ex RealtimeWeb.Dashboard.NodeInfo.fetch_node_data/2 score=42.00
  lib/realtime_web/dashboard/recon_trace.ex RealtimeWeb.Dashboard.ReconTrace.handle_event/3 score=552.00
  lib/realtime_web/dashboard/recon_trace.ex RealtimeWeb.Dashboard.ReconTrace.handle_info/2 score=210.00
  lib/realtime_web/dashboard/recon_trace.ex RealtimeWeb.Dashboard.ReconTrace.render_value/2 score=42.00
  lib/realtime_web/dashboard/recon_trace.ex RealtimeWeb.Dashboard.ReconTrace.load_module_functions/1 score=42.00
  lib/realtime_web/dashboard/recon_trace.ex RealtimeWeb.Dashboard.ReconTrace.parse_and_start/2 score=110.00
  lib/realtime_web/dashboard/recon_trace.ex RealtimeWeb.Dashboard.ReconTrace.format_value/1 score=552.00
  lib/realtime_web/dashboard/recon_trace.ex RealtimeWeb.Dashboard.ReconTrace.sort_entries/2 score=42.00
  lib/realtime_web/dashboard/sql_inspector.ex RealtimeWeb.Dashboard.SqlInspector.handle_event/3 score=72.00
  lib/realtime_web/dashboard/sql_inspector.ex RealtimeWeb.Dashboard.SqlInspector.execute_read_only/1 score=72.00
  lib/realtime_web/dashboard/sql_inspector.ex RealtimeWeb.Dashboard.SqlInspector.mask_sensitive_columns/1 score=42.00
  lib/realtime_web/dashboard/sql_inspector.ex RealtimeWeb.Dashboard.SqlInspector.compare_cells/2 score=42.00
  lib/realtime_web/dashboard/sql_inspector.ex RealtimeWeb.Dashboard.SqlInspector.format_cell/1 score=72.00
  lib/realtime_web/dashboard/tenant_migrations.ex RealtimeWeb.Dashboard.TenantMigrations.handle_info/2 score=156.00
  lib/realtime_web/live/components.ex RealtimeWeb.Components.input/1 score=90.00
  lib/realtime_web/live/inspector_live/conn_component.ex RealtimeWeb.InspectorLive.ConnComponent.handle_event/3 score=53.83
  lib/realtime_web/live/inspector_live/event_log_component.ex RealtimeWeb.InspectorLive.EventLogComponent.category_variant/1 score=35.00
  lib/realtime_web/live/inspector_live/event_log_component.ex RealtimeWeb.InspectorLive.EventLogComponent.event_label/1 score=76.13
  lib/realtime_web/live/status_live/index.ex RealtimeWeb.StatusLive.Index.handle_event/3 score=42.00

@coveralls

Copy link
Copy Markdown

Coverage Status

Coverage is 91.602% — chore/introduce-errata into main. No base build found for main.

Comment thread lib/realtime/messages/replay_failed.ex
"""
use Errata.InfrastructureError,
default_message: "Realtime was unable to replay messages",
code: "UnableToReplayMessages"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I notice these both have the same code and neither matches the module name. With error modules as specific to a single path/feature as these are, do we end up with hundreds of error modules and is that a problem? Do we need to plan out a taxonomy of errors to avoid that?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I certainly hope we won't have that many error modules, but planning out the error taxonomy is an important part of integrating Errata into the codebase.

The code is intended for external error reporters and aggregators, and I think it would be fairly common for multiple Errata errors to share a code so that they can be aggregated together.

Does that answer your questions?

backend overwrites top-level `context`, `level` and `stacktrace` metadata with its own.
"""
@spec log_error(socket :: Phoenix.Socket.t(), error :: Errata.error()) :: {:error, %{reason: binary}}
def log_error(socket, error) when is_error(error) do

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Do we need to think about log sanitisation here? Since we're logging more context it could be easy for someone to accidentally log something that shouldn't be logged.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good call out. Errata supports redaction of sensitive data both at the individual error level and at global config level, but I hadn't configured it yet. In commit dddcda3 I added the following global config:

config :errata,
  redact: [
    # credentials clients send
    :access_token,
    :user_token,
    :auth_token,
    :apikey,
    "x-api-key",
    :authorization,

    # tenant secrets
    :jwt_secret,
    :db_password,
    :password
  ]

assert :erpc.call(node, Messages, :replay, [pid, tenant.external_id, "test", 0, 30]) ==
{:error, :failed_to_replay_messages}
# The error is created on the remote node and travels back as a struct, context and all
assert {:error, %ReplayFailed{} = error} =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

What's the impact on the wire if we're sending more than an atom back as an error message from an rpc call? And is that worth it if in most cases we're just logging the error?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It will certainly increase the amount of data sent over the wire, but only when errors are being passed around nodes. I wouldn't think this would be happening frequently enough to be of concern, although I'm willing to be corrected on that.

Sensitive data will now be redacted when errors propagate to logs or any other external error reporting system.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants