defmodule Provenance do @moduledoc """ Universal artifact lineage graph for Elixir. Tracks what code produced what data -- across modules, queries, requests, and background jobs. Every artifact gets a deterministic, searchable ID. ## Quick start # Query the module graph {:ok, info} = Provenance.lookup("mod:MyApp.Orders") info.layer #=> :context info.source_file #=> "lib/my_app/orders.ex" info.dependencies #=> ["mod:MyApp.Orders.Order", "mod:MyApp.Repo"] # Record provenance for an operation Provenance.record("rec:orders:42", origin: "fn:MyApp.Orders.create_order/1") # Query: what code touches this table? Provenance.related("tbl:orders") #=> ["mod:MyApp.Orders", "mod:MyApp.Orders.Order", "mig:20240315_add_status"] ## Provenance IDs Every artifact is identified by a `kind:identifier` string: - `mod:MyApp.Orders` -- a module - `fn:MyApp.Orders.get_order/1` -- a function - `tbl:orders` -- a database table - `rec:orders:42` -- a specific record - `req:F1a2b3c4d5` -- an HTTP request """ alias Provenance.Store @type artifact_id :: String.t() @type artifact_info :: %{ id: artifact_id(), kind: atom(), layer: atom() | nil, source_file: String.t() | nil, metadata: map() } @doc "Look up an artifact by its provenance ID." @spec lookup(artifact_id()) :: {:ok, artifact_info()} | :not_found def lookup(id) when is_binary(id) do Store.lookup(id) end @doc "Record a provenance relationship: `artifact_id` was produced by `origin`." @spec record(artifact_id(), keyword()) :: :ok def record(artifact_id, opts \\ []) when is_binary(artifact_id) do origin = Keyword.get(opts, :origin) metadata = Keyword.get(opts, :metadata, %{}) Store.record(artifact_id, origin, metadata) end @doc "Find all artifacts related to the given ID (producers, consumers, co-located)." @spec related(artifact_id()) :: [artifact_id()] def related(id) when is_binary(id) do Store.related(id) end @doc "Register a module with its metadata (called by the compile tracer)." @spec register_module(module(), keyword()) :: :ok def register_module(module, opts \\ []) when is_atom(module) do Store.register_module(module, opts) end @doc "Get the full module dependency graph." @spec module_graph() :: %{artifact_id() => [artifact_id()]} def module_graph do Store.module_graph() end @doc "Build a provenance ID for a module." @spec module_id(module()) :: artifact_id() def module_id(module) when is_atom(module) do "mod:#{inspect(module)}" end @doc "Build a provenance ID for a function." @spec function_id(module(), atom(), non_neg_integer()) :: artifact_id() def function_id(module, function, arity) when is_atom(module) and is_atom(function) and is_integer(arity) do "fn:#{inspect(module)}.#{function}/#{arity}" end @doc "Build a provenance ID for a table." @spec table_id(String.t()) :: artifact_id() def table_id(table) when is_binary(table), do: "tbl:#{table}" @doc "Build a provenance ID for a record." @spec record_id(String.t(), term()) :: artifact_id() def record_id(table, pk), do: "rec:#{table}:#{pk}" @doc """ Attach provenance context to the current process. This context is automatically included in any provenance recordings made within the process (e.g., Ecto queries, log entries). """ @spec put_context(keyword()) :: :ok def put_context(attrs) when is_list(attrs) do existing = Process.get(:provenance_context, %{}) Process.put(:provenance_context, Map.merge(existing, Map.new(attrs))) :ok end @doc "Get the provenance context for the current process." @spec get_context() :: map() def get_context do Process.get(:provenance_context, %{}) end end