PropertyDamage.Forensics (PropertyDamage v0.2.0)

View Source

Replay production event logs through model projections for incident analysis.

When a production incident occurs, you can use Forensics to replay the actual events through your test projections. This reuses all your invariant checks and state tracking to pinpoint exactly when and how something went wrong.

Why Forensics?

Instead of manually tracing through logs, let the same models and projections that verify correctness during testing analyze production behavior:

  1. Same invariant checks - Production events are verified against the same assertions used in property tests
  2. State reconstruction - See exactly what state the system was in at each step
  3. Failure localization - Pinpoint the exact event that violated an invariant
  4. Reusable models - No need to write separate incident analysis code

Usage

# Fetch events from your observability system
{:ok, events} = ProductionLogs.fetch(trace_id: "abc123")

# Replay through model projections
result = PropertyDamage.Forensics.analyze(
  events: events,
  model: OrderModel,
  event_mapping: MyEventMapping  # Optional: translate production format
)

case result do
  {:ok, %{final_state: state}} ->
    IO.puts("No invariant violations detected")

  {:error, failure} ->
    IO.puts("Found violation at step #{failure.failure_step}")
    IO.puts(PropertyDamage.Forensics.format_report(failure))
end

Event Mapping

Production events often have different field names or structures. Implement an event mapping module to translate them:

defmodule MyEventMapping do
  @behaviour PropertyDamage.Forensics.EventMapping

  @impl true
  def map(%{"type" => "order.created", "payload" => p}) do
    {:ok, %OrderCreated{order_ref: p["order_id"], amount: p["total"]}}
  end

  def map(_), do: :skip
end

Limitations

  • Events must be self-describing (contain enough context to reconstruct state)
  • Assertions using every: :command won't trigger (forensics has no commands)
  • Event ordering must match production ordering

Summary

Types

Analysis result - either success or failure.

Failed analysis result.

Successful analysis result.

Functions

Analyze a sequence of production events against a model.

Format a failure report as a human-readable string.

Generate a regression test from a forensic failure.

Types

analysis_result()

@type analysis_result() :: {:ok, success_result()} | {:error, failure_result()}

Analysis result - either success or failure.

failure_result()

@type failure_result() :: %{
  failure_reason: term(),
  failure_step: non_neg_integer(),
  event_at_failure: struct() | map(),
  state_before: map(),
  state_after: map(),
  events_leading_to_failure: [struct() | map()]
}

Failed analysis result.

success_result()

@type success_result() :: %{
  final_state: map(),
  events_processed: non_neg_integer(),
  projections: %{required(module()) => term()}
}

Successful analysis result.

Functions

analyze(opts)

@spec analyze(keyword()) :: analysis_result()

Analyze a sequence of production events against a model.

Replays events through the model's projections, running assertion checks after each event. Stops at the first invariant violation (by default).

Options

  • :model - The model module (required)
  • :event_mapping - Module to translate production events (optional)
  • :stop_on_first_failure - Stop at first invariant violation (default: true)
  • :projections - Override which assertion projections to use (default: model's)

Returns

  • {:ok, success_result} - All events processed without violations
  • {:error, failure_result} - An invariant was violated

Examples

# Basic usage
Forensics.analyze(events: events, model: MyModel)

# With event mapping
Forensics.analyze(
  events: production_events,
  model: MyModel,
  event_mapping: MyMapper
)

# Continue past failures
Forensics.analyze(
  events: events,
  model: MyModel,
  stop_on_first_failure: false
)

format_report(failure)

@spec format_report(failure_result()) :: String.t()

Format a failure report as a human-readable string.

Example

{:error, failure} = Forensics.analyze(events: events, model: MyModel)
IO.puts(Forensics.format_report(failure))

# Output:
# ═══════════════════════════════════════════════════════════════════
# FORENSIC ANALYSIS: INVARIANT VIOLATION DETECTED
# ═══════════════════════════════════════════════════════════════════
#
# FAILURE OCCURRED AT: Event #47
# ...

generate_regression_test(failure, model)

@spec generate_regression_test(failure_result(), module()) :: String.t()

Generate a regression test from a forensic failure.

Creates Elixir code that can be added to your test suite to reproduce the failure scenario with the exact events from production.

Example

{:error, failure} = Forensics.analyze(events: events, model: MyModel)
test_code = Forensics.generate_regression_test(failure, MyModel)
File.write!("test/regressions/incident_2025_01_15_test.exs", test_code)