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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## 0.2.0 - 2025-12-01

### Added

- Extensions support: Allow custom PostgreSQL functions to be executed after each change is logged
- New `irontrail_change_callbacks` table to register extension functions

## 0.1.8 - 2025-10-22

### Changed
Expand Down
119 changes: 119 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,125 @@ The gem is not necessary for the capture to work, but it provides a few niceties
such as allowing metadata (e.g. currently logged in user, current request info)
to be tracked and easy setup and testing utilities.

## Extensions

IronTrail supports extensions, which allow you to execute custom PostgreSQL functions
after each change is logged. This is useful for implementing custom business logic,
notifications, or data transformations that need to happen immediately after a change
is captured.

### How Extensions Work

After every insert into the `irontrail_changes` table, the trigger function automatically
queries the `irontrail_change_callbacks` table to find any registered extension functions for
that specific table. If any enabled extensions are found, they are executed in order.

Each extension function runs with its own exception handling, so if one extension fails,
it gets logged to `irontrail_trigger_errors` and the remaining extensions continue to execute.
This ensures that a buggy extension cannot break your change logging or affect other extensions.

### Setting Up an Extension

1. First, create your custom PostgreSQL function that will be called. The function must
accept three parameters:
- `change_id` (BIGINT) - The ID of the newly created irontrail_changes record
- `rec_table` (TEXT) - The name of the table that was changed
- `operation` (TEXT) - The operation type ('i' for insert, 'u' for update, 'd' for delete)

To access the record data, query the `irontrail_changes` table using the provided `change_id`.

Example extension function:

```sql
CREATE OR REPLACE FUNCTION notify_user_changes(
change_id BIGINT,
rec_table TEXT,
operation TEXT
) RETURNS VOID AS $$
DECLARE
change_record RECORD;
BEGIN
-- Query the change data using the change_id
SELECT rec_old, rec_new INTO change_record
FROM irontrail_changes
WHERE id = change_id;

-- Your custom logic here
IF operation = 'u' THEN
RAISE NOTICE 'User % changed from % to %',
change_record.rec_new->>'id',
change_record.rec_old->>'email',
change_record.rec_new->>'email';
ELSIF operation = 'i' THEN
RAISE NOTICE 'New user % created: %',
change_record.rec_new->>'id',
change_record.rec_new->>'email';
ELSIF operation = 'd' THEN
RAISE NOTICE 'User % deleted: %',
change_record.rec_old->>'id',
change_record.rec_old->>'email';
END IF;
END;
$$ LANGUAGE plpgsql;
```

2. Register the extension in the `irontrail_change_callbacks` table:

```sql
INSERT INTO irontrail_change_callbacks (rec_table, function_name, enabled, created_at, updated_at)
VALUES ('users', 'notify_user_changes', true, NOW(), NOW());
```

Or in Ruby/Rails:

```ruby
ActiveRecord::Base.connection.execute(<<~SQL)
INSERT INTO irontrail_change_callbacks (rec_table, function_name, enabled, created_at, updated_at)
VALUES ('users', 'notify_user_changes', true, NOW(), NOW())
SQL
```

3. Now, every time a change is made to the `users` table, your `notify_user_changes`
function will be called automatically.

### Disabling an Extension

To temporarily disable an extension without removing it:

```sql
UPDATE irontrail_change_callbacks
SET enabled = false
WHERE rec_table = 'users' AND function_name = 'notify_user_changes';
```

### Extension Error Handling

When an extension function encounters an error, IronTrail automatically catches the exception
and logs it to the `irontrail_trigger_errors` table without affecting the change logging or
other extensions. The error log includes:

- The extension function name
- The `change_id` that was being processed
- Full PostgreSQL error details (error code, message, detail, hint, context)
- The OLD and NEW row data for debugging

To view extension errors:

```sql
SELECT * FROM irontrail_trigger_errors
WHERE query LIKE 'Extension function:%'
ORDER BY created_at DESC;
```

### Important Notes

- Extension functions are executed within the same transaction as the change
- If an extension function raises an exception, it will be caught and logged to the `irontrail_trigger_errors` table
- Extension failures do NOT prevent the change from being logged or affect other extensions
- Other extensions will continue to execute even if one fails
- Extension functions should be fast and efficient to avoid performance issues
- You can have multiple extensions for the same table - they will all be executed in order

## Install

Just add to your Gemfile:
Expand Down
41 changes: 27 additions & 14 deletions lib/generators/iron_trail/migration_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,33 @@ class MigrationGenerator < Rails::Generators::Base
def create_changes_migration_file
migration_dir = File.expand_path("db/migrate")

migration_template(
"create_irontrail_changes.rb.erb",
"db/migrate/create_irontrail_changes.rb"
)

migration_template(
"create_irontrail_support_tables.rb.erb",
"db/migrate/create_irontrail_support_tables.rb"
)

migration_template(
"create_irontrail_trigger_function.rb.erb",
"db/migrate/create_irontrail_trigger_function.rb"
)
unless self.class.migration_exists?(migration_dir, "create_irontrail_changes")
Copy link
Member

Choose a reason for hiding this comment

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

we don't think these unlesss are necessary.

migration_template(
"create_irontrail_changes.rb.erb",
"db/migrate/create_irontrail_changes.rb"
)
end

unless self.class.migration_exists?(migration_dir, "create_irontrail_support_tables")
migration_template(
"create_irontrail_support_tables.rb.erb",
"db/migrate/create_irontrail_support_tables.rb"
)
end

unless self.class.migration_exists?(migration_dir, "create_irontrail_trigger_function")
migration_template(
"create_irontrail_trigger_function.rb.erb",
"db/migrate/create_irontrail_trigger_function.rb"
)
end

unless self.class.migration_exists?(migration_dir, "create_irontrail_change_callbacks")
migration_template(
"create_irontrail_change_callbacks.rb.erb",
"db/migrate/create_irontrail_change_callbacks.rb"
)
end
end

def self.next_migration_number(dirname)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
class CreateIrontrailChangeCallbacks < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
def up
create_table :irontrail_change_callbacks, id: :bigserial do |t|
Copy link
Member

Choose a reason for hiding this comment

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

I'd call the feature Iron Trail Extensions instead of callbacks.

t.column :rec_table, :text, null: false
t.column :function_name, :text, null: false
t.column :enabled, :boolean, default: true, null: false
Copy link
Member

Choose a reason for hiding this comment

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

No need for enable. Record can be created/deleted as needed to serve the same purpose.

t.column :created_at, :timestamp
t.column :updated_at, :timestamp
end

add_index :irontrail_change_callbacks, :rec_table
add_index :irontrail_change_callbacks, [:rec_table, :enabled]

IronTrail::DbFunctions.new(connection).tap do |db_fun|
db_fun.install_function('irontrail_log_row_function_with_callbacks')
end
end

def down
drop_table :irontrail_change_callbacks

IronTrail::DbFunctions.new(connection).tap do |db_fun|
db_fun.install_function('irontrail_log_row_function')
end
end
end
4 changes: 4 additions & 0 deletions lib/iron_trail.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ module IronTrail
irontrail_changes
].freeze

OWN_TRACKABLE_TABLES = %w[
irontrail_change_callbacks
].freeze
Copy link
Member

Choose a reason for hiding this comment

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

remove this constant and declare table on OWN_TABLES above


module SchemaDumper
def trailer(stream)
if IronTrail.enabled?
Expand Down
6 changes: 6 additions & 0 deletions lib/iron_trail/db_functions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ def initialize(connection)
@connection = connection
end

def install_function(function_name)
path = File.expand_path("#{function_name}.sql", __dir__)
sql = File.read(path)
connection.execute(sql)
end
Copy link
Member

Choose a reason for hiding this comment

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

why this new method and a new irontrail_log_row_function_with_callbacks.sql file duplicating the function that already exists on irontrail_log_row_function.sql?


# Creates the SQL functions in the DB. It will not run the function or create
# any triggers.
def install_functions
Expand Down
2 changes: 1 addition & 1 deletion lib/iron_trail/irontrail_log_row_function.sql
Original file line number Diff line number Diff line change
Expand Up @@ -93,4 +93,4 @@ EXCEPTION
TG_OP, TG_TABLE_NAME, row_to_json(OLD), row_to_json(NEW), current_query(), STATEMENT_TIMESTAMP());
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
$$ LANGUAGE plpgsql;
136 changes: 136 additions & 0 deletions lib/iron_trail/irontrail_log_row_function_with_callbacks.sql
Copy link
Member

Choose a reason for hiding this comment

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

any reason to create this file instead of using the existing one?

Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
CREATE OR REPLACE FUNCTION irontrail_log_row()
RETURNS TRIGGER AS $$
DECLARE
u_changes JSONB;
key TEXT;
it_meta TEXT;
it_meta_obj JSONB;
value_a JSONB;
value_b JSONB;
old_obj JSONB;
new_obj JSONB;
actor_type TEXT;
actor_id TEXT;
created_at TIMESTAMP;
change_id BIGINT;
ext_func_name TEXT;
operation_char TEXT;

err_text TEXT; err_detail TEXT; err_hint TEXT; err_ctx TEXT;
BEGIN
SELECT split_part(split_part(current_query(), '/*IronTrail ', 2), ' IronTrail*/', 1) INTO it_meta;

IF (it_meta <> '') THEN
it_meta_obj = it_meta::JSONB;

IF (it_meta_obj ? '_actor_type') THEN
actor_type = it_meta_obj->>'_actor_type';
it_meta_obj = it_meta_obj - '_actor_type';
END IF;
IF (it_meta_obj ? '_actor_id') THEN
actor_id = it_meta_obj->>'_actor_id';
it_meta_obj = it_meta_obj - '_actor_id';
END IF;
END IF;

old_obj = row_to_json(OLD);
new_obj = row_to_json(NEW);

IF (TG_OP = 'INSERT' AND new_obj ? 'created_at') THEN
created_at = NEW.created_at;
ELSIF (TG_OP = 'UPDATE' AND new_obj ? 'updated_at') THEN
IF (NEW.updated_at <> OLD.updated_at) THEN
created_at = NEW.updated_at;
END IF;
END IF;

IF (created_at IS NULL) THEN
created_at = STATEMENT_TIMESTAMP();
ELSE
it_meta_obj = jsonb_set(COALESCE(it_meta_obj, '{}'::jsonb), array['_db_created_at'], TO_JSONB(STATEMENT_TIMESTAMP()));
END IF;

IF (TG_OP = 'INSERT') THEN
INSERT INTO "irontrail_changes" ("actor_id", "actor_type",
"rec_table", "operation", "rec_id", "rec_new", "metadata", "created_at")
VALUES (actor_id, actor_type,
TG_TABLE_NAME, 'i', NEW.id, new_obj, it_meta_obj, created_at)
RETURNING id INTO change_id;

operation_char = 'i';

ELSIF (TG_OP = 'UPDATE') THEN
IF (OLD <> NEW) THEN
u_changes = jsonb_build_object();

FOR key IN (SELECT jsonb_object_keys(old_obj) UNION SELECT jsonb_object_keys(new_obj))
LOOP
value_a := old_obj->key;
value_b := new_obj->key;
IF value_a IS DISTINCT FROM value_b THEN
u_changes := u_changes || jsonb_build_object(key, jsonb_build_array(value_a, value_b));
END IF;
END LOOP;

INSERT INTO "irontrail_changes" ("actor_id", "actor_type", "rec_table", "operation",
"rec_id", "rec_old", "rec_new", "rec_delta", "metadata", "created_at")
VALUES (actor_id, actor_type, TG_TABLE_NAME, 'u', NEW.id, old_obj, new_obj, u_changes, it_meta_obj, created_at)
RETURNING id INTO change_id;

operation_char = 'u';

END IF;
ELSIF (TG_OP = 'DELETE') THEN
INSERT INTO "irontrail_changes" ("actor_id", "actor_type", "rec_table", "operation",
"rec_id", "rec_old", "metadata", "created_at")
VALUES (actor_id, actor_type, TG_TABLE_NAME, 'd', OLD.id, old_obj, it_meta_obj, created_at)
RETURNING id INTO change_id;

operation_char = 'd';
END IF;

IF (change_id IS NOT NULL) THEN
Copy link
Member

Choose a reason for hiding this comment

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

was this if necessary in your tests? it seems to be checking something that should always be satisfied

FOR ext_func_name IN
SELECT function_name
FROM irontrail_change_callbacks
WHERE rec_table = TG_TABLE_NAME::TEXT AND enabled = true
LOOP
BEGIN
EXECUTE format('SELECT %I($1, $2, $3)', ext_func_name)
USING change_id, TG_TABLE_NAME::TEXT, operation_char;
EXCEPTION
WHEN OTHERS THEN
GET STACKED DIAGNOSTICS
err_text = MESSAGE_TEXT,
err_detail = PG_EXCEPTION_DETAIL,
err_hint = PG_EXCEPTION_HINT,
err_ctx = PG_EXCEPTION_CONTEXT;

INSERT INTO "irontrail_trigger_errors" ("pg_errcode", "pg_message",
"err_text", "ex_detail", "ex_hint", "ex_ctx", "op", "table_name",
"old_data", "new_data", "query", "created_at")
VALUES (SQLSTATE, SQLERRM, err_text, err_detail, err_hint,
'Extension: ' || ext_func_name || E'\n' || err_ctx,
TG_OP, TG_TABLE_NAME, row_to_json(OLD), row_to_json(NEW),
'Extension function: ' || ext_func_name || ' for change_id: ' || change_id,
STATEMENT_TIMESTAMP());
Copy link
Member

Choose a reason for hiding this comment

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

we should add some sort of indication that this error is from an extension (and which one in a structured way) and not the irontrail core.

END;
END LOOP;
END IF;
RETURN NULL;
EXCEPTION
WHEN OTHERS THEN
GET STACKED DIAGNOSTICS
err_text = MESSAGE_TEXT,
err_detail = PG_EXCEPTION_DETAIL,
err_hint = PG_EXCEPTION_HINT,
err_ctx = PG_EXCEPTION_CONTEXT;

INSERT INTO "irontrail_trigger_errors" ("pg_errcode", "pg_message",
"err_text", "ex_detail", "ex_hint", "ex_ctx", "op", "table_name",
"old_data", "new_data", "query", "created_at")
VALUES (SQLSTATE, SQLERRM, err_text, err_detail, err_hint, err_ctx,
TG_OP, TG_TABLE_NAME, row_to_json(OLD), row_to_json(NEW), current_query(), STATEMENT_TIMESTAMP());
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
2 changes: 1 addition & 1 deletion lib/iron_trail/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_literal_string: true

module IronTrail
VERSION = '0.1.8'
VERSION = '0.2.0'
end
Loading