Commit fd781d966a for asterisk.org

commit fd781d966a9be7cce946ca1ba27ea3447bfdfdeb
Author: George Joseph <gjoseph@sangoma.com>
Date:   Tue Jan 27 12:57:21 2026 -0700

    CDR/CEL Custom Performance Improvements

    There is a LOT of work in this commit but the TL;DR is that it takes
    CEL processing from using 38% of the CPU instructions used by a call,
    which is more than that used by the call processing itself, down to less
    than 10% of the instructions.

    So here's the deal...  cdr_custom, cdr_sqlite3_custom, cel_custom
    and cel_sqlite3_custom all shared one ugly trait...they all used
    ast_str_substitute_variables() or pbx_substitute_variables_helper()
    to resolve the dialplan functions used in their config files.  Not only
    are they both extraordinarily expensive, they both require a dummy
    channel to be allocated and destroyed for each record written.  For CDRs,
    that's not too bad because we only write one CDR per call.  For CELs however,
    it's a disaster.

    As far as source code goes, the modules basically all did the same thing.
    Unfortunately, they did it badly.  The config files simply contained long
    opaque strings which were intepreted by ast_str_substitute_variables() or
    pbx_substitute_variables_helper(), the very functions that ate all the
    instructions.  This meant introducing a new "advanced" config format much
    like the advanced manager event filtering added to manager.conf in 2024.
    Fortunately however, if the legacy config was recognizable, we were able to
    parse it as an advanced config and gain the benefit.  If not, then it
    goes the legacy, and very expensive, route.

    Given the commonality among the modules, instead of making the same
    improvements to 4 modules then trying to maintain them over time, a single
    module "res_cdrel_custom" was created that contains all of the common code.
    A few bonuses became possible in the process...
    * The cdr_custom and cel_custom modules now support JSON formatted output.
    * The cdr_sqlite_custom and cel_sqlite3_custom modules no longer have
      to share an Sqlite3 database.

    Summary of changes:

    A new module "res/res_cdrel_custom.c" has been created and the existing
    cdr_custom, cdr_sqlite3_custom, cel_custom and cel_sqlite3_custom modules
    are now just stubs that call the code in res_cdrel_custom.

    res_cdrel_custom contains:
    * A common configuration facility.
    * Getters for both CDR and CEL fields that share the same abstraction.
    * Formatters for all data types found in the ast_cdr and ast_event
      structures that share the same abstraction.
    * Common writers for the text file and database backends that, you guessed it,
      share the same abstraction.

    The result is that while there is certainly a net increase in the number of
    lines in the code base, most of it is in the configuration handling at
    load-time.  The run-time instruction path length is now significanty shorter.

    ```
    Scenario                   Instructions     Latency
    =====================================================
    CEL pre changes                  38.49%     37.51%
    CEL Advanced                      9.68%      6.06%
    CEL Legacy (auto-conv to adv)     9.95%      6.13%

    CEL Sqlite3 pre changes          39.41%     39.90%
    CEL Sqlite3 Advanced             25.68%     24.24%
    CEL Sqlite3 Legacy (auto conv)   25.88%     24.53%

    CDR pre changes                   4.79%      2.95%
    CDR Advanced                      0.79%      0.47%
    CDR Legacy (auto conv to adv)     0.86%      0.51%

    CDR Sqlite3 pre changes           4.47%      2.89%
    CEL Sqlite3 Advanced              2.16%      1.29%
    CEL Sqlite3 Legacy (auto conv)    2.19%      1.30%
    ```

    Notes:
    * We only write one CDR per call but every little bit helps.
    * Sqlite3 still takes a fair amount of resources but the new config
      makes a decent improvement.
    * Legacy configs that we can't auto convert will still take the
      "pre changes" path.

    If you're interested in more implementation details, see the comments
    at the top of the res_cdrel_custom.c file.

    One minor fix to CEL is also included...Although TenantID was added to the
    ast_event structure, it was always rendered as an empty string.  It's now
    properly rendered.

    UserNote: Significant performance improvements have been made to the
    cdr_custom, cdr_sqlite3_custom, cel_custom and cel_sqlite3_custom modules.
    See the new sample config files for those modules to see how to benefit
    from them.

diff --git a/cdr/cdr_custom.c b/cdr/cdr_custom.c
index e520263732..b44d12cef9 100644
--- a/cdr/cdr_custom.c
+++ b/cdr/cdr_custom.c
@@ -28,6 +28,9 @@
  *
  * Logs in LOG_DIR/cdr_custom
  * \ingroup cdr_drivers
+ *
+ * The logic for this module now resides in res/res_cdrel_custom.c.
+ *
  */

 /*! \li \ref cdr_custom.c uses the configuration file \ref cdr_custom.conf
@@ -40,192 +43,73 @@
  */

 /*** MODULEINFO
+	<depend>res_cdrel_custom</depend>
 	<support_level>core</support_level>
  ***/

 #include "asterisk.h"

-#include <time.h>
-
-#include "asterisk/paths.h"	/* use ast_config_AST_LOG_DIR */
-#include "asterisk/channel.h"
 #include "asterisk/cdr.h"
 #include "asterisk/module.h"
-#include "asterisk/config.h"
-#include "asterisk/pbx.h"
-#include "asterisk/utils.h"
-#include "asterisk/lock.h"
-#include "asterisk/threadstorage.h"
-#include "asterisk/strings.h"
+#include "asterisk/res_cdrel_custom.h"

 #define CONFIG "cdr_custom.conf"

-AST_THREADSTORAGE(custom_buf);
+#define CUSTOM_BACKEND_NAME "CDR File custom backend"

-static const char name[] = "cdr-custom";
+static struct cdrel_configs *configs;

-struct cdr_custom_config {
-	AST_DECLARE_STRING_FIELDS(
-		AST_STRING_FIELD(filename);
-		AST_STRING_FIELD(format);
-		);
-	ast_mutex_t lock;
-	AST_RWLIST_ENTRY(cdr_custom_config) list;
-};
-
-static AST_RWLIST_HEAD_STATIC(sinks, cdr_custom_config);
-
-static void free_config(void)
-{
-	struct cdr_custom_config *sink;
+/*!
+ * Protects in-flight log transactions from reloads.
+ */
+static ast_rwlock_t configs_lock;

-	while ((sink = AST_RWLIST_REMOVE_HEAD(&sinks, list))) {
-		ast_mutex_destroy(&sink->lock);
-		ast_string_field_free_memory(sink);
-		ast_free(sink);
-	}
-}
+#define CDREL_RECORD_TYPE cdrel_record_cdr
+#define CDREL_BACKEND_TYPE cdrel_backend_text

-static int load_config(void)
+static int custom_log(struct ast_cdr *cdr)
 {
-	struct ast_config *cfg;
-	struct ast_variable *var;
-	struct ast_flags config_flags = { 0 };
 	int res = 0;

-	cfg = ast_config_load(CONFIG, config_flags);
-	if (!cfg || cfg == CONFIG_STATUS_FILEINVALID) {
-		ast_log(LOG_ERROR, "Unable to load " CONFIG ". Not logging custom CSV CDRs.\n");
-		return -1;
-	}
-
-	var = ast_variable_browse(cfg, "mappings");
-	while (var) {
-		if (!ast_strlen_zero(var->name) && !ast_strlen_zero(var->value)) {
-			struct cdr_custom_config *sink = ast_calloc_with_stringfields(1, struct cdr_custom_config, 1024);
-
-			if (!sink) {
-				ast_log(LOG_ERROR, "Unable to allocate memory for configuration settings.\n");
-				res = -2;
-				break;
-			}
-
-			ast_string_field_build(sink, format, "%s\n", var->value);
-			if (var->name[0] == '/') {
-				ast_string_field_build(sink, filename, "%s", var->name);
-			} else {
-				ast_string_field_build(sink, filename, "%s/%s/%s", ast_config_AST_LOG_DIR, name, var->name);
-			}
-			ast_mutex_init(&sink->lock);
-
-			AST_RWLIST_INSERT_TAIL(&sinks, sink, list);
-		} else {
-			ast_log(LOG_NOTICE, "Mapping must have both a filename and a format at line %d\n", var->lineno);
-		}
-		var = var->next;
-	}
-	ast_config_destroy(cfg);
+	ast_rwlock_rdlock(&configs_lock);
+	res = cdrel_logger(configs, cdr);
+	ast_rwlock_unlock(&configs_lock);

 	return res;
 }

-static int custom_log(struct ast_cdr *cdr)
-{
-	struct ast_channel *dummy;
-	struct ast_str *str;
-	struct cdr_custom_config *config;
-
-	/* Batching saves memory management here.  Otherwise, it's the same as doing an allocation and free each time. */
-	if (!(str = ast_str_thread_get(&custom_buf, 16))) {
-		return -1;
-	}
-
-	dummy = ast_dummy_channel_alloc();
-	if (!dummy) {
-		ast_log(LOG_ERROR, "Unable to allocate channel for variable substitution.\n");
-		return -1;
-	}
-
-	/* We need to dup here since the cdr actually belongs to the other channel,
-	   so when we release this channel we don't want the CDR getting cleaned
-	   up prematurely. */
-	ast_channel_cdr_set(dummy, ast_cdr_dup(cdr));
-
-	AST_RWLIST_RDLOCK(&sinks);
-
-	AST_LIST_TRAVERSE(&sinks, config, list) {
-		FILE *out;
-
-		ast_str_substitute_variables(&str, 0, dummy, config->format);
-
-		/* Even though we have a lock on the list, we could be being chased by
-		   another thread and this lock ensures that we won't step on anyone's
-		   toes.  Once each CDR backend gets it's own thread, this lock can be
-		   removed. */
-		ast_mutex_lock(&config->lock);
-
-		/* Because of the absolutely unconditional need for the
-		   highest reliability possible in writing billing records,
-		   we open write and close the log file each time */
-		if ((out = fopen(config->filename, "a"))) {
-			fputs(ast_str_buffer(str), out);
-			fflush(out); /* be particularly anal here */
-			fclose(out);
-		} else {
-			ast_log(LOG_ERROR, "Unable to re-open master file %s : %s\n", config->filename, strerror(errno));
-		}
-
-		ast_mutex_unlock(&config->lock);
-	}
-
-	AST_RWLIST_UNLOCK(&sinks);
-
-	ast_channel_unref(dummy);
-
-	return 0;
-}
-
 static int unload_module(void)
 {
-	if (ast_cdr_unregister(name)) {
-		return -1;
-	}
+	int res = 0;

-	if (AST_RWLIST_WRLOCK(&sinks)) {
-		ast_cdr_register(name, ast_module_info->description, custom_log);
-		ast_log(LOG_ERROR, "Unable to lock sink list.  Unload failed.\n");
-		return -1;
+	ast_rwlock_wrlock(&configs_lock);
+	res = cdrel_unload_module(CDREL_BACKEND_TYPE, CDREL_RECORD_TYPE, configs, CUSTOM_BACKEND_NAME);
+	ast_rwlock_unlock(&configs_lock);
+	if (res == 0) {
+		ast_rwlock_destroy(&configs_lock);
 	}

-	free_config();
-	AST_RWLIST_UNLOCK(&sinks);
-	return 0;
+	return res;
 }

 static enum ast_module_load_result load_module(void)
 {
-	if (AST_RWLIST_WRLOCK(&sinks)) {
-		ast_log(LOG_ERROR, "Unable to lock sink list.  Load failed.\n");
+	if (ast_rwlock_init(&configs_lock) != 0) {
 		return AST_MODULE_LOAD_DECLINE;
 	}

-	load_config();
-	AST_RWLIST_UNLOCK(&sinks);
-	ast_cdr_register(name, ast_module_info->description, custom_log);
-	return AST_MODULE_LOAD_SUCCESS;
+	configs = cdrel_load_module(CDREL_BACKEND_TYPE, CDREL_RECORD_TYPE, CONFIG, CUSTOM_BACKEND_NAME, custom_log);
+
+	return configs ? AST_MODULE_LOAD_SUCCESS : AST_MODULE_LOAD_DECLINE;
 }

 static int reload(void)
 {
-	if (AST_RWLIST_WRLOCK(&sinks)) {
-		ast_log(LOG_ERROR, "Unable to lock sink list.  Load failed.\n");
-		return AST_MODULE_LOAD_DECLINE;
-	}
-
-	free_config();
-	load_config();
-	AST_RWLIST_UNLOCK(&sinks);
-	return AST_MODULE_LOAD_SUCCESS;
+	int res = 0;
+	ast_rwlock_wrlock(&configs_lock);
+	res = cdrel_reload_module(CDREL_BACKEND_TYPE, CDREL_RECORD_TYPE, &configs, CONFIG);
+	ast_rwlock_unlock(&configs_lock);
+	return res;
 }

 AST_MODULE_INFO(ASTERISK_GPL_KEY, AST_MODFLAG_LOAD_ORDER, "Customizable Comma Separated Values CDR Backend",
@@ -234,5 +118,5 @@ AST_MODULE_INFO(ASTERISK_GPL_KEY, AST_MODFLAG_LOAD_ORDER, "Customizable Comma Se
 	.unload = unload_module,
 	.reload = reload,
 	.load_pri = AST_MODPRI_CDR_DRIVER,
-	.requires = "cdr",
+	.requires = "cdr,res_cdrel_custom",
 );
diff --git a/cdr/cdr_sqlite3_custom.c b/cdr/cdr_sqlite3_custom.c
index 4df94d4666..1aa7ad742c 100644
--- a/cdr/cdr_sqlite3_custom.c
+++ b/cdr/cdr_sqlite3_custom.c
@@ -25,14 +25,16 @@
  *  cdr_mysql_custom by Edward Eastman <ed@dm3.co.uk>,
  *	and cdr_sqlite by Holger Schurig <hs4233@mail.mn-solutions.de>
  *
- *
  * \arg See also \ref AstCDR
  *
- *
  * \ingroup cdr_drivers
+ *
+ * The logic for this module now resides in res/res_cdrel_custom.c.
+ *
  */

 /*** MODULEINFO
+	<depend>res_cdrel_custom</depend>
 	<depend>sqlite3</depend>
 	<support_level>extended</support_level>
  ***/
@@ -41,316 +43,66 @@

 #include <sqlite3.h>

-#include "asterisk/paths.h"	/* use ast_config_AST_LOG_DIR */
-#include "asterisk/channel.h"
 #include "asterisk/cdr.h"
 #include "asterisk/module.h"
-#include "asterisk/config.h"
-#include "asterisk/pbx.h"
-#include "asterisk/utils.h"
-#include "asterisk/cli.h"
-#include "asterisk/app.h"
-
-AST_MUTEX_DEFINE_STATIC(lock);
-
-static const char config_file[] = "cdr_sqlite3_custom.conf";
-
-static const char desc[] = "Customizable SQLite3 CDR Backend";
-static const char name[] = "cdr_sqlite3_custom";
-static sqlite3 *db = NULL;
-
-static char table[80];
-static char *columns;
-static int busy_timeout;
-
-struct values {
-	AST_LIST_ENTRY(values) list;
-	char expression[1];
-};
-
-static AST_LIST_HEAD_STATIC(sql_values, values);
-
-static void free_config(int reload);
-
-static int load_column_config(const char *tmp)
-{
-	char *col = NULL;
-	char *cols = NULL, *save = NULL;
-	char *escaped = NULL;
-	struct ast_str *column_string = NULL;
-
-	if (ast_strlen_zero(tmp)) {
-		ast_log(LOG_WARNING, "Column names not specified. Module not loaded.\n");
-		return -1;
-	}
-	if (!(column_string = ast_str_create(1024))) {
-		ast_log(LOG_ERROR, "Out of memory creating temporary buffer for column list for table '%s.'\n", table);
-		return -1;
-	}
-	if (!(save = cols = ast_strdup(tmp))) {
-		ast_log(LOG_ERROR, "Out of memory creating temporary buffer for column list for table '%s.'\n", table);
-		ast_free(column_string);
-		return -1;
-	}
-	while ((col = strsep(&cols, ","))) {
-		col = ast_strip(col);
-		escaped = sqlite3_mprintf("%q", col);
-		if (!escaped) {
-			ast_log(LOG_ERROR, "Out of memory creating entry for column '%s' in table '%s.'\n", col, table);
-			ast_free(column_string);
-			ast_free(save);
-			return -1;
-		}
-		ast_str_append(&column_string, 0, "%s%s", ast_str_strlen(column_string) ? "," : "", escaped);
-		sqlite3_free(escaped);
-	}
-	if (!(columns = ast_strdup(ast_str_buffer(column_string)))) {
-		ast_log(LOG_ERROR, "Out of memory copying columns string for table '%s.'\n", table);
-		ast_free(column_string);
-		ast_free(save);
-		return -1;
-	}
-	ast_free(column_string);
-	ast_free(save);
-
-	return 0;
-}
-
-static int load_values_config(const char *tmp)
-{
-	char *vals = NULL, *save = NULL;
-	struct values *value = NULL;
-	int i;
-	AST_DECLARE_APP_ARGS(val,
-		AST_APP_ARG(ues)[200]; /* More than 200 columns in this CDR?  Yeah, right... */
-	);
-
-	if (ast_strlen_zero(tmp)) {
-		ast_log(LOG_WARNING, "Values not specified. Module not loaded.\n");
-		return -1;
-	}
-	if (!(save = vals = ast_strdup(tmp))) {
-		ast_log(LOG_ERROR, "Out of memory creating temporary buffer for value '%s'\n", tmp);
-		return -1;
-	}
-	AST_STANDARD_RAW_ARGS(val, vals);
-	for (i = 0; i < val.argc; i++) {
-		/* Strip the single quotes off if they are there */
-		char *v = ast_strip_quoted(val.ues[i], "'", "'");
-		value = ast_calloc(sizeof(char), sizeof(*value) + strlen(v));
-		if (!value) {
-			ast_log(LOG_ERROR, "Out of memory creating entry for value '%s'\n", v);
-			ast_free(save);
-			return -1;
-		}
-		strcpy(value->expression, v); /* SAFE */
-		AST_LIST_INSERT_TAIL(&sql_values, value, list);
-	}
-	ast_free(save);
-
-	return 0;
-}
-
-static int load_config(int reload)
-{
-	struct ast_config *cfg;
-	struct ast_flags config_flags = { reload ? CONFIG_FLAG_FILEUNCHANGED : 0 };
-	const char *tmp;
-
-	if ((cfg = ast_config_load(config_file, config_flags)) == CONFIG_STATUS_FILEMISSING || cfg == CONFIG_STATUS_FILEINVALID) {
-		ast_log(LOG_WARNING, "Failed to %sload configuration file. %s\n", reload ? "re" : "", reload ? "" : "Module not activated.");
-		return -1;
-	} else if (cfg == CONFIG_STATUS_FILEUNCHANGED) {
-		return 0;
-	}
-
-	if (reload) {
-		free_config(1);
-	}
-
-	if (!ast_variable_browse(cfg, "master")) {
-		/* Nothing configured */
-		ast_config_destroy(cfg);
-		return -1;
-	}
-
-	/* Mapping must have a table name */
-	if (!ast_strlen_zero(tmp = ast_variable_retrieve(cfg, "master", "table"))) {
-		ast_copy_string(table, tmp, sizeof(table));
-	} else {
-		ast_log(LOG_WARNING, "Table name not specified.  Assuming cdr.\n");
-		strcpy(table, "cdr");
-	}
+#include "asterisk/res_cdrel_custom.h"

-	/* sqlite3_busy_timeout in miliseconds */
-	if ((tmp = ast_variable_retrieve(cfg, "master", "busy_timeout")) != NULL) {
-		if (ast_parse_arg(tmp, PARSE_INT32|PARSE_DEFAULT, &busy_timeout, 1000) != 0) {
-			ast_log(LOG_WARNING, "Invalid busy_timeout value '%s' specified. Using 1000 instead.\n", tmp);
-		}
-	} else {
-		busy_timeout = 1000;
-	}
-
-	/* Columns */
-	if (load_column_config(ast_variable_retrieve(cfg, "master", "columns"))) {
-		ast_config_destroy(cfg);
-		free_config(0);
-		return -1;
-	}
-
-	/* Values */
-	if (load_values_config(ast_variable_retrieve(cfg, "master", "values"))) {
-		ast_config_destroy(cfg);
-		free_config(0);
-		return -1;
-	}
+#define CONFIG "cdr_sqlite3_custom.conf"

-	ast_verb(4, "cdr_sqlite3_custom: Logging CDR records to table '%s' in 'master.db'\n", table);
-
-	ast_config_destroy(cfg);
-
-	return 0;
-}
-
-static void free_config(int reload)
-{
-	struct values *value;
+#define CUSTOM_BACKEND_NAME "CDR sqlite3 custom backend"

-	if (!reload && db) {
-		sqlite3_close(db);
-		db = NULL;
-	}
+static struct cdrel_configs *configs;

-	if (columns) {
-		ast_free(columns);
-		columns = NULL;
-	}
+/*!
+ * Protects in-flight log transactions from reloads.
+ */
+static ast_rwlock_t configs_lock;

-	while ((value = AST_LIST_REMOVE_HEAD(&sql_values, list))) {
-		ast_free(value);
-	}
-}
+#define CDREL_RECORD_TYPE cdrel_record_cdr
+#define CDREL_BACKEND_TYPE cdrel_backend_db

-static int write_cdr(struct ast_cdr *cdr)
+static int custom_log(struct ast_cdr *cdr)
 {
 	int res = 0;
-	char *error = NULL;
-	char *sql = NULL;
-
-	if (db == NULL) {
-		/* Should not have loaded, but be failsafe. */
-		return 0;
-	}

-	ast_mutex_lock(&lock);
-
-	{ /* Make it obvious that only sql should be used outside of this block */
-		char *escaped;
-		char subst_buf[2048];
-		struct values *value;
-		struct ast_channel *dummy;
-		struct ast_str *value_string = ast_str_create(1024);
-
-		dummy = ast_dummy_channel_alloc();
-		if (!dummy) {
-			ast_log(LOG_ERROR, "Unable to allocate channel for variable substitution.\n");
-			ast_free(value_string);
-			ast_mutex_unlock(&lock);
-			return 0;
-		}
-		ast_channel_cdr_set(dummy, ast_cdr_dup(cdr));
-		AST_LIST_TRAVERSE(&sql_values, value, list) {
-			pbx_substitute_variables_helper(dummy, value->expression, subst_buf, sizeof(subst_buf) - 1);
-			escaped = sqlite3_mprintf("%q", subst_buf);
-			ast_str_append(&value_string, 0, "%s'%s'", ast_str_strlen(value_string) ? "," : "", escaped);
-			sqlite3_free(escaped);
-		}
-		sql = sqlite3_mprintf("INSERT INTO %q (%s) VALUES (%s)", table, columns, ast_str_buffer(value_string));
-		ast_debug(1, "About to log: %s\n", sql);
-		ast_channel_unref(dummy);
-		ast_free(value_string);
-	}
-
-	if (sqlite3_exec(db, sql, NULL, NULL, &error) != SQLITE_OK) {
-		ast_log(LOG_ERROR, "%s. SQL: %s.\n", error, sql);
-		sqlite3_free(error);
-	}
-
-	if (sql) {
-		sqlite3_free(sql);
-	}
-
-	ast_mutex_unlock(&lock);
+	ast_rwlock_rdlock(&configs_lock);
+	res = cdrel_logger(configs, cdr);
+	ast_rwlock_unlock(&configs_lock);

 	return res;
 }

 static int unload_module(void)
 {
-	if (ast_cdr_unregister(name)) {
-		return -1;
-	}
+	int res = 0;

-	free_config(0);
+	ast_rwlock_wrlock(&configs_lock);
+	res = cdrel_unload_module(CDREL_BACKEND_TYPE, CDREL_RECORD_TYPE, configs, CUSTOM_BACKEND_NAME);
+	ast_rwlock_unlock(&configs_lock);
+	if (res == 0) {
+		ast_rwlock_destroy(&configs_lock);
+	}

-	return 0;
+	return res;
 }

-static int load_module(void)
+static enum ast_module_load_result load_module(void)
 {
-	char *error;
-	char filename[PATH_MAX];
-	int res;
-	char *sql;
-
-	if (load_config(0)) {
+	if (ast_rwlock_init(&configs_lock) != 0) {
 		return AST_MODULE_LOAD_DECLINE;
 	}

-	/* is the database there? */
-	snprintf(filename, sizeof(filename), "%s/master.db", ast_config_AST_LOG_DIR);
-	res = sqlite3_open(filename, &db);
-	if (res != SQLITE_OK) {
-		ast_log(LOG_ERROR, "Could not open database %s.\n", filename);
-		free_config(0);
-		return AST_MODULE_LOAD_DECLINE;
-	}
-	sqlite3_busy_timeout(db, busy_timeout);
-	/* is the table there? */
-	sql = sqlite3_mprintf("SELECT COUNT(AcctId) FROM %q;", table);
-	res = sqlite3_exec(db, sql, NULL, NULL, NULL);
-	sqlite3_free(sql);
-	if (res != SQLITE_OK) {
-		/* We don't use %q for the column list here since we already escaped when building it */
-		sql = sqlite3_mprintf("CREATE TABLE %q (AcctId INTEGER PRIMARY KEY, %s)", table, columns);
-		res = sqlite3_exec(db, sql, NULL, NULL, &error);
-		sqlite3_free(sql);
-		if (res != SQLITE_OK) {
-			ast_log(LOG_WARNING, "Unable to create table '%s': %s.\n", table, error);
-			sqlite3_free(error);
-			free_config(0);
-			return AST_MODULE_LOAD_DECLINE;
-		}
-	}
+	configs = cdrel_load_module(CDREL_BACKEND_TYPE, CDREL_RECORD_TYPE, CONFIG, CUSTOM_BACKEND_NAME, custom_log);

-	res = ast_cdr_register(name, desc, write_cdr);
-	if (res) {
-		ast_log(LOG_ERROR, "Unable to register custom SQLite3 CDR handling\n");
-		free_config(0);
-		return AST_MODULE_LOAD_DECLINE;
-	}
-
-	return AST_MODULE_LOAD_SUCCESS;
+	return configs ? AST_MODULE_LOAD_SUCCESS : AST_MODULE_LOAD_DECLINE;
 }

 static int reload(void)
 {
 	int res = 0;
-
-	ast_mutex_lock(&lock);
-	res = load_config(1);
-	ast_mutex_unlock(&lock);
-
+	ast_rwlock_wrlock(&configs_lock);
+	res = cdrel_reload_module(CDREL_BACKEND_TYPE, CDREL_RECORD_TYPE, &configs, CONFIG);
+	ast_rwlock_unlock(&configs_lock);
 	return res;
 }

@@ -360,5 +112,5 @@ AST_MODULE_INFO(ASTERISK_GPL_KEY, AST_MODFLAG_LOAD_ORDER, "SQLite3 Custom CDR Mo
 	.unload = unload_module,
 	.reload = reload,
 	.load_pri = AST_MODPRI_CDR_DRIVER,
-	.requires = "cdr",
+	.requires = "cdr,res_cdrel_custom",
 );
diff --git a/cel/cel_custom.c b/cel/cel_custom.c
index 5f391e1f21..3c158e44f1 100644
--- a/cel/cel_custom.c
+++ b/cel/cel_custom.c
@@ -24,197 +24,75 @@
  * \author Steve Murphy <murf@digium.com>
  * Logs in LOG_DIR/cel_custom
  * \ingroup cel_drivers
+ *
+ * The logic for this module now resides in res/res_cdrel_custom.c.
+ *
  */

 /*** MODULEINFO
+	<depend>res_cdrel_custom</depend>
 	<support_level>core</support_level>
  ***/

 #include "asterisk.h"

-#include "asterisk/paths.h"
-#include "asterisk/channel.h"
 #include "asterisk/cel.h"
 #include "asterisk/module.h"
-#include "asterisk/config.h"
-#include "asterisk/pbx.h"
-#include "asterisk/utils.h"
-#include "asterisk/lock.h"
-#include "asterisk/threadstorage.h"
-#include "asterisk/strings.h"
+#include "asterisk/res_cdrel_custom.h"

 #define CONFIG "cel_custom.conf"

-AST_THREADSTORAGE(custom_buf);
-
-static const char name[] = "cel-custom";
-
-struct cel_config {
-	AST_DECLARE_STRING_FIELDS(
-		AST_STRING_FIELD(filename);
-		AST_STRING_FIELD(format);
-	);
-	ast_mutex_t lock;
-	AST_RWLIST_ENTRY(cel_config) list;
-};
-
 #define CUSTOM_BACKEND_NAME "CEL Custom CSV Logging"

-static AST_RWLIST_HEAD_STATIC(sinks, cel_config);
-
-static void free_config(void)
-{
-	struct cel_config *sink;
-
-	while ((sink = AST_RWLIST_REMOVE_HEAD(&sinks, list))) {
-		ast_mutex_destroy(&sink->lock);
-		ast_string_field_free_memory(sink);
-		ast_free(sink);
-	}
-}
-
-static int load_config(void)
-{
-	struct ast_config *cfg;
-	struct ast_variable *var;
-	struct ast_flags config_flags = { 0 };
-	int mappings = 0;
-	int res = 0;
-
-	cfg = ast_config_load(CONFIG, config_flags);
-	if (!cfg || cfg == CONFIG_STATUS_FILEINVALID) {
-		ast_log(LOG_ERROR, "Unable to load " CONFIG ". Not logging CEL to custom CSVs.\n");
-		return -1;
-	}
-
-	if (!(var = ast_variable_browse(cfg, "mappings"))) {
-		ast_log(LOG_NOTICE, "No mappings found in " CONFIG ". Not logging CEL to custom CSVs.\n");
-	}
+static struct cdrel_configs *configs;

-	while (var) {
-		if (!ast_strlen_zero(var->name) && !ast_strlen_zero(var->value)) {
-			struct cel_config *sink = ast_calloc_with_stringfields(1, struct cel_config, 1024);
-
-			if (!sink) {
-				ast_log(LOG_ERROR, "Unable to allocate memory for configuration settings.\n");
-				res = -2;
-				break;
-			}
-
-			ast_string_field_build(sink, format, "%s\n", var->value);
-			if (var->name[0] == '/') {
-				ast_string_field_build(sink, filename, "%s", var->name);
-			} else {
-				ast_string_field_build(sink, filename, "%s/%s/%s", ast_config_AST_LOG_DIR, name, var->name);
-			}
-			ast_mutex_init(&sink->lock);
-
-			ast_verb(3, "Added CEL CSV mapping for '%s'.\n", sink->filename);
-			mappings += 1;
-			AST_RWLIST_INSERT_TAIL(&sinks, sink, list);
-		} else {
-			ast_log(LOG_NOTICE, "Mapping must have both a filename and a format at line %d\n", var->lineno);
-		}
-		var = var->next;
-	}
-	ast_config_destroy(cfg);
-
-	ast_verb(1, "Added CEL CSV mapping for %d files.\n", mappings);
+/*!
+ * Protects in-flight log transactions from reloads.
+ */
+static ast_rwlock_t configs_lock;

-	return res;
-}
+#define CDREL_RECORD_TYPE cdrel_record_cel
+#define CDREL_BACKEND_TYPE cdrel_backend_text

 static void custom_log(struct ast_event *event)
 {
-	struct ast_channel *dummy;
-	struct ast_str *str;
-	struct cel_config *config;
-
-	/* Batching saves memory management here.  Otherwise, it's the same as doing an allocation and free each time. */
-	if (!(str = ast_str_thread_get(&custom_buf, 16))) {
-		return;
-	}
-
-	dummy = ast_cel_fabricate_channel_from_event(event);
-	if (!dummy) {
-		ast_log(LOG_ERROR, "Unable to fabricate channel from CEL event.\n");
-		return;
-	}
-
-	AST_RWLIST_RDLOCK(&sinks);
-
-	AST_LIST_TRAVERSE(&sinks, config, list) {
-		FILE *out;
-
-		ast_str_substitute_variables(&str, 0, dummy, config->format);
-
-		/* Even though we have a lock on the list, we could be being chased by
-		   another thread and this lock ensures that we won't step on anyone's
-		   toes.  Once each CEL backend gets it's own thread, this lock can be
-		   removed. */
-		ast_mutex_lock(&config->lock);
-
-		/* Because of the absolutely unconditional need for the
-		   highest reliability possible in writing billing records,
-		   we open write and close the log file each time */
-		if ((out = fopen(config->filename, "a"))) {
-			fputs(ast_str_buffer(str), out);
-			fflush(out); /* be particularly anal here */
-			fclose(out);
-		} else {
-			ast_log(LOG_ERROR, "Unable to re-open master file %s : %s\n", config->filename, strerror(errno));
-		}
-
-		ast_mutex_unlock(&config->lock);
-	}
-
-	AST_RWLIST_UNLOCK(&sinks);
-
-	ast_channel_unref(dummy);
+	ast_rwlock_rdlock(&configs_lock);
+	cdrel_logger(configs, event);
+	ast_rwlock_unlock(&configs_lock);
 }

 static int unload_module(void)
 {
+	int res = 0;

-	if (AST_RWLIST_WRLOCK(&sinks)) {
-		ast_log(LOG_ERROR, "Unable to lock sink list.  Unload failed.\n");
-		return -1;
+	ast_rwlock_wrlock(&configs_lock);
+	res = cdrel_unload_module(CDREL_BACKEND_TYPE, CDREL_RECORD_TYPE, configs, CUSTOM_BACKEND_NAME);
+	ast_rwlock_unlock(&configs_lock);
+	if (res == 0) {
+		ast_rwlock_destroy(&configs_lock);
 	}

-	free_config();
-	AST_RWLIST_UNLOCK(&sinks);
-	ast_cel_backend_unregister(CUSTOM_BACKEND_NAME);
-	return 0;
+	return res;
 }

 static enum ast_module_load_result load_module(void)
 {
-	if (AST_RWLIST_WRLOCK(&sinks)) {
-		ast_log(LOG_ERROR, "Unable to lock sink list.  Load failed.\n");
+	if (ast_rwlock_init(&configs_lock) != 0) {
 		return AST_MODULE_LOAD_DECLINE;
 	}

-	load_config();
-	AST_RWLIST_UNLOCK(&sinks);
+	configs = cdrel_load_module(CDREL_BACKEND_TYPE, CDREL_RECORD_TYPE, CONFIG, CUSTOM_BACKEND_NAME, custom_log);

-	if (ast_cel_backend_register(CUSTOM_BACKEND_NAME, custom_log)) {
-		free_config();
-		return AST_MODULE_LOAD_DECLINE;
-	}
-	return AST_MODULE_LOAD_SUCCESS;
+	return configs ? AST_MODULE_LOAD_SUCCESS : AST_MODULE_LOAD_DECLINE;
 }

 static int reload(void)
 {
-	if (AST_RWLIST_WRLOCK(&sinks)) {
-		ast_log(LOG_ERROR, "Unable to lock sink list.  Load failed.\n");
-		return AST_MODULE_LOAD_DECLINE;
-	}
-
-	free_config();
-	load_config();
-	AST_RWLIST_UNLOCK(&sinks);
-	return AST_MODULE_LOAD_SUCCESS;
+	int res = 0;
+	ast_rwlock_wrlock(&configs_lock);
+	res = cdrel_reload_module(CDREL_BACKEND_TYPE, CDREL_RECORD_TYPE, &configs, CONFIG);
+	ast_rwlock_unlock(&configs_lock);
+	return res;
 }

 AST_MODULE_INFO(ASTERISK_GPL_KEY, AST_MODFLAG_LOAD_ORDER, "Customizable Comma Separated Values CEL Backend",
@@ -223,5 +101,5 @@ AST_MODULE_INFO(ASTERISK_GPL_KEY, AST_MODFLAG_LOAD_ORDER, "Customizable Comma Se
 	.unload = unload_module,
 	.reload = reload,
 	.load_pri = AST_MODPRI_CDR_DRIVER,
-	.requires = "cel",
+	.requires = "cel,res_cdrel_custom",
 );
diff --git a/cel/cel_sqlite3_custom.c b/cel/cel_sqlite3_custom.c
index 23a5b43b03..a5acf774e1 100644
--- a/cel/cel_sqlite3_custom.c
+++ b/cel/cel_sqlite3_custom.c
@@ -27,9 +27,13 @@
  *  cdr_mysql_custom by Edward Eastman <ed@dm3.co.uk>,
  *	and cdr_sqlite by Holger Schurig <hs4233@mail.mn-solutions.de>
  * \ingroup cel_drivers
+ *
+ * The logic for this module now resides in res/res_cdrel_custom.c.
+ *
  */

 /*** MODULEINFO
+	<depend>res_cdrel_custom</depend>
 	<depend>sqlite3</depend>
 	<support_level>extended</support_level>
  ***/
@@ -38,314 +42,62 @@

 #include <sqlite3.h>

-#include "asterisk/paths.h"
-#include "asterisk/channel.h"
 #include "asterisk/cel.h"
 #include "asterisk/module.h"
-#include "asterisk/config.h"
-#include "asterisk/pbx.h"
-#include "asterisk/logger.h"
-#include "asterisk/utils.h"
-#include "asterisk/cli.h"
-#include "asterisk/options.h"
-#include "asterisk/stringfields.h"
-
-#define SQLITE_BACKEND_NAME "CEL sqlite3 custom backend"
+#include "asterisk/res_cdrel_custom.h"

-AST_MUTEX_DEFINE_STATIC(lock);
+#define CONFIG "cel_sqlite3_custom.conf"

-static const char config_file[] = "cel_sqlite3_custom.conf";
+#define CUSTOM_BACKEND_NAME "CEL sqlite3 custom backend"

-static sqlite3 *db = NULL;
+static struct cdrel_configs *configs;

-static char table[80];
 /*!
- * \bug Handling of this var is crash prone on reloads
+ * Protects in-flight log transactions from reloads.
  */
-static char *columns;
-static int busy_timeout;
-
-struct values {
-	char *expression;
-	AST_LIST_ENTRY(values) list;
-};
-
-static AST_LIST_HEAD_STATIC(sql_values, values);
+static ast_rwlock_t configs_lock;

-static void free_config(void);
+#define CDREL_RECORD_TYPE cdrel_record_cel
+#define CDREL_BACKEND_TYPE cdrel_backend_db

-static int load_column_config(const char *tmp)
+static void custom_log(struct ast_event *event)
 {
-	char *col = NULL;
-	char *cols = NULL, *save = NULL;
-	char *escaped = NULL;
-	struct ast_str *column_string = NULL;
-
-	if (ast_strlen_zero(tmp)) {
-		ast_log(LOG_WARNING, "Column names not specified. Module not loaded.\n");
-		return -1;
-	}
-	if (!(column_string = ast_str_create(1024))) {
-		ast_log(LOG_ERROR, "Out of memory creating temporary buffer for column list for table '%s.'\n", table);
-		return -1;
-	}
-	if (!(save = cols = ast_strdup(tmp))) {
-		ast_log(LOG_ERROR, "Out of memory creating temporary buffer for column list for table '%s.'\n", table);
-		ast_free(column_string);
-		return -1;
-	}
-	while ((col = strsep(&cols, ","))) {
-		col = ast_strip(col);
-		escaped = sqlite3_mprintf("%q", col);
-		if (!escaped) {
-			ast_log(LOG_ERROR, "Out of memory creating entry for column '%s' in table '%s.'\n", col, table);
-			ast_free(column_string);
-			ast_free(save);
-			return -1;
-		}
-		ast_str_append(&column_string, 0, "%s%s", ast_str_strlen(column_string) ? "," : "", escaped);
-		sqlite3_free(escaped);
-	}
-	if (!(columns = ast_strdup(ast_str_buffer(column_string)))) {
-		ast_log(LOG_ERROR, "Out of memory copying columns string for table '%s.'\n", table);
-		ast_free(column_string);
-		ast_free(save);
-		return -1;
-	}
-	ast_free(column_string);
-	ast_free(save);
-
-	return 0;
-}
-
-static int load_values_config(const char *tmp)
-{
-	char *val = NULL;
-	char *vals = NULL, *save = NULL;
-	struct values *value = NULL;
-
-	if (ast_strlen_zero(tmp)) {
-		ast_log(LOG_WARNING, "Values not specified. Module not loaded.\n");
-		return -1;
-	}
-	if (!(save = vals = ast_strdup(tmp))) {
-		ast_log(LOG_ERROR, "Out of memory creating temporary buffer for value '%s'\n", tmp);
-		return -1;
-	}
-	while ((val = strsep(&vals, ","))) {
-		/* Strip the single quotes off if they are there */
-		val = ast_strip_quoted(val, "'", "'");
-		value = ast_calloc(sizeof(char), sizeof(*value) + strlen(val) + 1);
-		if (!value) {
-			ast_log(LOG_ERROR, "Out of memory creating entry for value '%s'\n", val);
-			ast_free(save);
-			return -1;
-		}
-		value->expression = (char *) value + sizeof(*value);
-		ast_copy_string(value->expression, val, strlen(val) + 1);
-		AST_LIST_INSERT_TAIL(&sql_values, value, list);
-	}
-	ast_free(save);
-
-	return 0;
-}
-
-static int load_config(int reload)
-{
-	struct ast_config *cfg;
-	struct ast_flags config_flags = { reload ? CONFIG_FLAG_FILEUNCHANGED : 0 };
-	struct ast_variable *mappingvar;
-	const char *tmp;
-
-	if ((cfg = ast_config_load(config_file, config_flags)) == CONFIG_STATUS_FILEMISSING || cfg == CONFIG_STATUS_FILEINVALID) {
-		ast_log(LOG_WARNING, "Failed to %sload configuration file. %s\n",
-			reload ? "re" : "", reload ? "" : "Module not activated.");
-		return -1;
-	} else if (cfg == CONFIG_STATUS_FILEUNCHANGED) {
-		return 0;
-	}
-
-	if (reload) {
-		free_config();
-	}
-
-	if (!(mappingvar = ast_variable_browse(cfg, "master"))) {
-		/* Nothing configured */
-		ast_config_destroy(cfg);
-		return -1;
-	}
-
-	/* Mapping must have a table name */
-	if (!ast_strlen_zero(tmp = ast_variable_retrieve(cfg, "master", "table"))) {
-		ast_copy_string(table, tmp, sizeof(table));
-	} else {
-		ast_log(LOG_WARNING, "Table name not specified.  Assuming cel.\n");
-		strcpy(table, "cel");
-	}
-
-	/* sqlite3_busy_timeout in miliseconds */
-	if ((tmp = ast_variable_retrieve(cfg, "master", "busy_timeout")) != NULL) {
-		if (ast_parse_arg(tmp, PARSE_INT32|PARSE_DEFAULT, &busy_timeout, 1000) != 0) {
-			ast_log(LOG_WARNING, "Invalid busy_timeout value '%s' specified. Using 1000 instead.\n", tmp);
-		}
-	} else {
-		busy_timeout = 1000;
-	}
-
-	/* Columns */
-	if (load_column_config(ast_variable_retrieve(cfg, "master", "columns"))) {
-		ast_config_destroy(cfg);
-		free_config();
-		return -1;
-	}
-
-	/* Values */
-	if (load_values_config(ast_variable_retrieve(cfg, "master", "values"))) {
-		ast_config_destroy(cfg);
-		free_config();
-		return -1;
-	}
-
-	ast_verb(3, "Logging CEL records to table '%s' in 'master.db'\n", table);
-
-	ast_config_destroy(cfg);
-
-	return 0;
-}
-
-static void free_config(void)
-{
-	struct values *value;
-
-	if (db) {
-		sqlite3_close(db);
-		db = NULL;
-	}
-
-	if (columns) {
-		ast_free(columns);
-		columns = NULL;
-	}
-
-	while ((value = AST_LIST_REMOVE_HEAD(&sql_values, list))) {
-		ast_free(value);
-	}
-}
-
-static void write_cel(struct ast_event *event)
-{
-	char *error = NULL;
-	char *sql = NULL;
-
-	if (db == NULL) {
-		/* Should not have loaded, but be failsafe. */
-		return;
-	}
-
-	ast_mutex_lock(&lock);
-
-	{ /* Make it obvious that only sql should be used outside of this block */
-		char *escaped;
-		char subst_buf[2048];
-		struct values *value;
-		struct ast_channel *dummy;
-		struct ast_str *value_string = ast_str_create(1024);
-
-		dummy = ast_cel_fabricate_channel_from_event(event);
-		if (!dummy) {
-			ast_log(LOG_ERROR, "Unable to fabricate channel from CEL event.\n");
-			ast_free(value_string);
-			ast_mutex_unlock(&lock);
-			return;
-		}
-		AST_LIST_TRAVERSE(&sql_values, value, list) {
-			pbx_substitute_variables_helper(dummy, value->expression, subst_buf, sizeof(subst_buf) - 1);
-			escaped = sqlite3_mprintf("%q", subst_buf);
-			ast_str_append(&value_string, 0, "%s'%s'", ast_str_strlen(value_string) ? "," : "", escaped);
-			sqlite3_free(escaped);
-		}
-		sql = sqlite3_mprintf("INSERT INTO %q (%s) VALUES (%s)", table, columns, ast_str_buffer(value_string));
-		ast_debug(1, "About to log: %s\n", sql);
-		dummy = ast_channel_unref(dummy);
-		ast_free(value_string);
-	}
-
-	if (sqlite3_exec(db, sql, NULL, NULL, &error) != SQLITE_OK) {
-		ast_log(LOG_ERROR, "%s. SQL: %s.\n", error, sql);
-		sqlite3_free(error);
-	}
-
-	if (sql) {
-		sqlite3_free(sql);
-	}
-	ast_mutex_unlock(&lock);
-
-	return;
+	ast_rwlock_rdlock(&configs_lock);
+	cdrel_logger(configs, event);
+	ast_rwlock_unlock(&configs_lock);
 }

 static int unload_module(void)
 {
-	ast_cel_backend_unregister(SQLITE_BACKEND_NAME);
+	int res = 0;

-	free_config();
+	ast_rwlock_wrlock(&configs_lock);
+	res = cdrel_unload_module(CDREL_BACKEND_TYPE, CDREL_RECORD_TYPE, configs, CUSTOM_BACKEND_NAME);
+	ast_rwlock_unlock(&configs_lock);
+	if (res == 0) {
+		ast_rwlock_destroy(&configs_lock);
+	}

-	return 0;
+	return res;
 }

-static int load_module(void)
+static enum ast_module_load_result load_module(void)
 {
-	char *error;
-	char filename[PATH_MAX];
-	int res;
-	char *sql;
-
-	if (load_config(0)) {
+	if (ast_rwlock_init(&configs_lock) != 0) {
 		return AST_MODULE_LOAD_DECLINE;
 	}

-	/* is the database there? */
-	snprintf(filename, sizeof(filename), "%s/master.db", ast_config_AST_LOG_DIR);
-	res = sqlite3_open(filename, &db);
-	if (res != SQLITE_OK) {
-		ast_log(LOG_ERROR, "Could not open database %s.\n", filename);
-		free_config();
-		return AST_MODULE_LOAD_DECLINE;
-	}
-	sqlite3_busy_timeout(db, busy_timeout);
-	/* is the table there? */
-	sql = sqlite3_mprintf("SELECT COUNT(*) FROM %q;", table);
-	res = sqlite3_exec(db, sql, NULL, NULL, NULL);
-	sqlite3_free(sql);
-	if (res != SQLITE_OK) {
-		/* We don't use %q for the column list here since we already escaped when building it */
-		sql = sqlite3_mprintf("CREATE TABLE %q (AcctId INTEGER PRIMARY KEY, %s)", table, columns);
-		res = sqlite3_exec(db, sql, NULL, NULL, &error);
-		sqlite3_free(sql);
-		if (res != SQLITE_OK) {
-			ast_log(LOG_WARNING, "Unable to create table '%s': %s.\n", table, error);
-			sqlite3_free(error);
-			free_config();
-			return AST_MODULE_LOAD_DECLINE;
-		}
-	}
+	configs = cdrel_load_module(CDREL_BACKEND_TYPE, CDREL_RECORD_TYPE, CONFIG, CUSTOM_BACKEND_NAME, custom_log);

-	if (ast_cel_backend_register(SQLITE_BACKEND_NAME, write_cel)) {
-		ast_log(LOG_ERROR, "Unable to register custom SQLite3 CEL handling\n");
-		free_config();
-		return AST_MODULE_LOAD_DECLINE;
-	}
-
-	return AST_MODULE_LOAD_SUCCESS;
+	return configs ? AST_MODULE_LOAD_SUCCESS : AST_MODULE_LOAD_DECLINE;
 }

 static int reload(void)
 {
 	int res = 0;
-
-	ast_mutex_lock(&lock);
-	res = load_config(1);
-	ast_mutex_unlock(&lock);
-
+	ast_rwlock_wrlock(&configs_lock);
+	res = cdrel_reload_module(CDREL_BACKEND_TYPE, CDREL_RECORD_TYPE, &configs, CONFIG);
+	ast_rwlock_unlock(&configs_lock);
 	return res;
 }

@@ -355,5 +107,5 @@ AST_MODULE_INFO(ASTERISK_GPL_KEY, AST_MODFLAG_LOAD_ORDER, "SQLite3 Custom CEL Mo
 	.unload = unload_module,
 	.reload = reload,
 	.load_pri = AST_MODPRI_CDR_DRIVER,
-	.requires = "cel",
+	.requires = "cel,res_cdrel_custom",
 );
diff --git a/configs/samples/cdr_custom.conf.sample b/configs/samples/cdr_custom.conf.sample
index b296e881b6..760c039eba 100644
--- a/configs/samples/cdr_custom.conf.sample
+++ b/configs/samples/cdr_custom.conf.sample
@@ -1,15 +1,39 @@
 ;
-; Mappings for custom config file
+; Asterisk Call Detail Record Logging (CDR) - Custom DSV Backend
 ;
-; To get your CSV output in a format tailored to your liking, uncomment the
-; following lines and look for the output in the cdr-custom directory (usually
-; in /var/log/asterisk).  Depending on which mapping you uncomment, you may see
-; Master.csv, Simple.csv, or both.
+
+; This is the configuration file for the customizable DSV backend for CDR
+; logging.
 ;
-; Alternatively, you can also specify the location of your CSV file using an
-; absolute path, e.g.:
+; DSV??  Delimiter Separated Values because the field delimiter doesn't have
+; to be a comma.
 ;
-;     /srv/pbx/cdr/Master.csv => ${CSV_QUOTE(${CDR(clid)})},...
+; Legacy vs Advanced Mappings
+;
+; Legacy mappings are those that are defined using dialplan functions like CDR
+; and CSV_QUOTE and require a VERY expensive function replacement process at
+; runtime for every record output.
+;
+; Advanced mappings are those that are defined by a list of field names and
+; parameters that define the field separator and quote character you want to use.
+; This type of mapping is uses significantly less resources at runtime.
+;
+;
+; Legacy Mappings
+;
+; Within a legacy mapping, use the CDR() and CSV_QUOTE() functions to retrieve
+; values from the CDR.
+;
+; NOTE: If your legacy mapping uses commas as field separators and only the CSV_QUOTE
+; and CDR dialplan functions, the module will attempt to strip the functions and
+; and create a much faster advanced mapping for it. However, we urge you to create
+; a real advanced mapping and not rely on this process. If the mapping contains
+; something not recognized it will go the slower legacy route.
+;
+; Each entry in the "mappings" category represents a separate output file.
+; A filename that starts with a forward-slash '/' will be treated as an absolute
+; path name.  If it doesn't, it must be a file name with no directory separators
+; which will be placed in the /var/log/asterisk/cdr-custom directory.
 ;
 ;[mappings]
 ;Master.csv => ${CSV_QUOTE(${CDR(clid)})},${CSV_QUOTE(${CDR(src)})},${CSV_QUOTE(${CDR(dst)})},${CSV_QUOTE(${CDR(dcontext)})},${CSV_QUOTE(${CDR(channel)})},${CSV_QUOTE(${CDR(dstchannel)})},${CSV_QUOTE(${CDR(lastapp)})},${CSV_QUOTE(${CDR(lastdata)})},${CSV_QUOTE(${CDR(start)})},${CSV_QUOTE(${CDR(answer)})},${CSV_QUOTE(${CDR(end)})},${CSV_QUOTE(${CDR(duration)})},${CSV_QUOTE(${CDR(billsec)})},${CSV_QUOTE(${CDR(disposition)})},${CSV_QUOTE(${CDR(amaflags)})},${CSV_QUOTE(${CDR(accountcode)})},${CSV_QUOTE(${CDR(uniqueid)})},${CSV_QUOTE(${CDR(userfield)})},${CDR(sequence)}
@@ -17,3 +41,103 @@
 ; High Resolution Time for billsec and duration fields
 ;Master.csv => ${CSV_QUOTE(${CDR(clid)})},${CSV_QUOTE(${CDR(src)})},${CSV_QUOTE(${CDR(dst)})},${CSV_QUOTE(${CDR(dcontext)})},${CSV_QUOTE(${CDR(channel)})},${CSV_QUOTE(${CDR(dstchannel)})},${CSV_QUOTE(${CDR(lastapp)})},${CSV_QUOTE(${CDR(lastdata)})},${CSV_QUOTE(${CDR(start)})},${CSV_QUOTE(${CDR(answer)})},${CSV_QUOTE(${CDR(end)})},${CSV_QUOTE(${CDR(duration,f)})},${CSV_QUOTE(${CDR(billsec,f)})},${CSV_QUOTE(${CDR(disposition)})},${CSV_QUOTE(${CDR(amaflags)})},${CSV_QUOTE(${CDR(accountcode)})},${CSV_QUOTE(${CDR(uniqueid)})},${CSV_QUOTE(${CDR(userfield)})},${CDR(sequence)}
 ;Simple.csv => ${CSV_QUOTE(${EPOCH})},${CSV_QUOTE(${CDR(src)})},${CSV_QUOTE(${CDR(dst)})}
+
+
+;
+; Advanced Mappings
+;
+; Each category in this file other than "mappings" represents a separate output file.
+; A filename that starts with a forward-slash '/' will be treated as an absolute
+; path name.  If it doesn't, it must be a file name with no directory separators
+; which will be placed in the /var/log/asterisk/cdr-custom directory.
+;
+;[cdr_master.csv]             ; Output file or path name.
+
+;format = dsv                 ; Advanced mappings can actually have two types
+                              ; "dsv" or "json".  This example uses "dsv", which
+                              ; is the default, but see the example below for more
+                              ; info on the json format.
+
+;separator_character = ,      ; The character to use for the field separator,
+                              ; It defaults to a comma but you can use any
+                              ; character you want.  For instance, specify
+                              ; \t to separate the fields with a tab.
+
+;quote_character = "          ; The character to use as the quote character.
+                              ; It defaults to the double quote but again, it
+                              ; can be any character you want although the
+                              ; single-quote is the only other one that makes
+                              ; sense.
+
+;quote_escape_character = "   ; The character to use to escape the quote_character
+                              ; should the quote character actually appear in the
+                              ; field output.  A good example of this is a caller
+                              ; id string like "My Name" <1000>.  For true CSV
+                              ; compatibility, it must be the same as the quote
+                              ; character but a backslash might be acceptable for
+                              ; other formats.
+
+;quoting_method = all         ; Nothing says you _have_ to quote anything. The only
+                              ; time a field MUST be quoted is if it contains the
+                              ; separator character and this is handled automatically.
+                              ; Additionally, the following options are available:
+                              ; "all": Quote all fields. The default.
+                              ; "non_numeric": Quote all non numeric fields.
+                              ; "none": Don't quote any field (unless it contains
+                              ; a separator character).
+
+;fields = clid,"Some Literal",src, dst, dcontext, channel, dstchannel, lastapp, lastdata, start, answer, end, duration, billsec, disposition, amaflags, accountcode, uniqueid(noquote), userfield, sequence,ds_type(uservar)
+    ; This is the list of fields to include in the record. The field names are the
+    ; same as in the legacy mapping but without any enclosing dialplan functions.
+    ; You can specify literals to be placed in the output record by double-quoting
+    ; them. There is also some special notation available in the form of "qualifiers".
+    ; A qualifier is a list of tags, separated by the '^' character and placed
+    ; directly after the field name and enclosed in parentheses.
+    ;
+    ; All fields can accept the "quote" or "noquote" qualifier when you want to exempt
+    ; a field from the "quoting_method" policy.  For example, when quoting_method=all,
+    ; you can force the uniqueid field to not be quoted with `uniqueid(noquote)`. The
+    ; example in fields above shows this.
+    ;
+    ; If you've added user variables to the CDR using the CDR() dialplan function in
+    ; extensions.conf, you'll need to retrieve the field using the "uservar" qualifier.
+    ; For example, if you've added the "ds_type" variable, you'll need to retrieve
+    ; it with `ds_type(uservar)`.
+    ;
+    ; The default output format for the "start", "answer" and "end" timestamp fields
+    ; is the "%Y-%m-%d %T" strftime string format however you can also format those
+    ; fields as an int64 or a float: `start(int64),answer(float),end`.
+    ;
+    ; The "disposition" and "amaflags" are formatted as their string names like
+    ; "ANSWERED" and "DOCUMENTATION" by default but if you just want the numbers and
+    ; not the names...  `amaflags(int64),disposition(int64)`.
+    ;
+    ; If you need to combine flags, use the caret '^' symbol: `start(int64^noquote)`
+    ;
+    ; Final notes about "fields":
+    ;    Field names and qualifiers aren't case sensitive.
+    ;    You MUST use the comma to separate the fields, not the "separator_character".
+    ;    You MUST use the double-quote to indicate literal fields, not the "quote_character".
+    ;    Whitespace in "fields" is ignored except in literals.
+
+;
+; An Advanced JSON example:
+;
+;[cdr_master.json]
+;format = json
+;fields = clid,"My: Literal",src, dst, dcontext, channel, dstchannel, lastapp, lastdata, start, answer, end, duration, billsec, disposition, amaflags, accountcode, uniqueid(noquote), userfield, sequence,ds_type(uservar)
+;
+;  In order to ensure valid JSON, the following settings are forced:
+;    The separator character is always the comma.
+;    The quote character is always the double-quote.
+;    The quote escape character is always the backslash.
+;    The quoting method is always "non_numeric".
+;
+;  Since JSON requires both a name and value, the name is always
+;  the field name.  For literals however you must specify the literal
+;  as a "name: value" pair as demonstrated above.  The output record
+;  would then look something like:
+; {"clid":"\"Alice\" <1000>", "My":"Literal","src":"1000","dst":"18005551212", ...}
+
+
+
diff --git a/configs/samples/cdr_sqlite3_custom.conf.sample b/configs/samples/cdr_sqlite3_custom.conf.sample
index 4b88d58d48..99f9b96fa6 100644
--- a/configs/samples/cdr_sqlite3_custom.conf.sample
+++ b/configs/samples/cdr_sqlite3_custom.conf.sample
@@ -1,11 +1,105 @@
 ;
-; Mappings for custom config file
+; Asterisk Call Detail Record Logging (CDR) - Custom Sqlite3 Backend
 ;
-[master] ; currently, only file "master.db" is supported, with only one table at a time.
-;table => cdr
-;columns => calldate, clid, dcontext, channel, dstchannel, lastapp, lastdata, duration, billsec, disposition, amaflags, accountcode, uniqueid, userfield, test
-;values => '${CDR(start)}','${CDR(clid)}','${CDR(dcontext)}','${CDR(channel)}','${CDR(dstchannel)}','${CDR(lastapp)}','${CDR(lastdata)}','${CDR(duration)}','${CDR(billsec)}','${CDR(disposition)}','${CDR(amaflags)}','${CDR(accountcode)}','${CDR(uniqueid)}','${CDR(userfield)}','${CDR(test)}'
-;busy_timeout => 1000

-;Enable High Resolution Times for billsec and duration fields
-;values => '${CDR(start)}','${CDR(clid)}','${CDR(dcontext)}','${CDR(channel)}','${CDR(dstchannel)}','${CDR(lastapp)}','${CDR(lastdata)}','${CDR(duration,f)}','${CDR(billsec,f)}','${CDR(disposition)}','${CDR(amaflags)}','${CDR(accountcode)}','${CDR(uniqueid)}','${CDR(userfield)}','${CDR(test)}'
+; This is the configuration file for the customizable Sqlite3 backend for CDR
+; logging.
+;
+; Legacy vs Advanced Mappings
+;
+; Legacy mappings are those that are defined using dialplan functions like CDR
+; and CSV_QUOTE and require a VERY expensive function replacement process at
+; runtime for every record output.
+;
+; Advanced mappings are those that are defined by a simple list of field names
+; which uses significantly less resources at runtime.
+;
+;
+; Each category in this file represents a separate Sqlite3 database file.
+; A filename that starts with a forward-slash '/' will be treated as an absolute
+; path name.  If it doesn't, it must be a file name with no directory separators
+; which will be placed in the /var/log/asterisk directory.  If the database
+; file doesn't already exist, it will be created.
+;
+; Previous versions of Asterisk limited the output to a single database file
+; named "master" which was shared with CDR Sqlite3 logging.  That is no longer
+; the case.
+;
+; Legacy Mappings
+;
+; Within a legacy mapping, use the CDR() and CSV_QUOTE() functions to retrieve
+; values from the CDR.
+;
+; NOTE: If your legacy mapping uses only the CDR dialplan function, the module
+; will attempt to strip the functions and create a much faster advanced mapping
+; for it. However, we urge you to create a real advanced mapping and not rely
+; on this process. If the mapping contains something not recognized it will go
+; the slower legacy route.
+;
+;
+;[cdr_master]
+;table = cdr             ; The name of the table in the database into which records
+                         ; are to be written.  If the table doesn't already exist,
+                         ; it will be created.
+
+;columns = calldate, literal, clid, dcontext, channel, dstchannel, lastapp, lastdata, duration, billsec, disposition, amaflags, accountcode, uniqueid, userfield, ds_store
+    ; The column names to receive the fields.  If the table doesn't already exist,
+    ; it will be created with these columns.  If the table does exist, this list
+    ; MUST match the existing columns or the config will fail to load.
+    ; The column names do NOT have to match the field names however.
+
+;values = '${CDR(start)}','some literal','${CDR(clid)}','${CDR(dcontext)}','${CDR(channel)}','${CDR(dstchannel)}','${CDR(lastapp)}','${CDR(lastdata)}','${CDR(duration)}','${CDR(billsec)}','${CDR(disposition)}','${CDR(amaflags)}','${CDR(accountcode)}','${CDR(uniqueid)}','${CDR(userfield)}','${CDR(ds_store)}'
+    ; The list of fields to write into the columns.
+    ; Each field MUST be enclosed in single-quotes and the fields separated
+    ; by commas.  Additionally, the number of fields specified MUST match the
+    ; number of columns or the config will fail to load.
+
+;busy_timeout = 1000    ; The number of milliseconds to wait for a database operation
+                        ; to complete before an error is returned.
+
+;
+; Advanced Mappings
+;
+;[cdr_advanced]
+;table = cdr             ; The name of the table in the database into which records
+                         ; are to be written.  If the table doesn't already exist,
+                         ; it will be created.
+
+;columns = calldate, literal, clid, dcontext, channel, dstchannel, lastapp, lastdata, duration, billsec, disposition, amaflags, accountcode, uniqueid, userfield, ds_storetest    ; The column names to receive the fields.  If the table doesn't already exist,
+    ; it will be created with these columns.  If the table does exist, this list
+    ; MUST match the existing columns or the config will fail to load.
+
+;fields = start,"some literal",clid,dcontext,channel,dstchannel,lastapp,lastdata,duration,billsec,disposition,amaflags,accountcode,uniqueid,userfield,ds_store(uservar)
+    ; The "fields" parameter differentiates this mapping as an Advanced one
+    ; as opposed to "values" used above.
+    ;
+    ; This is the list of fields to include in the record. The field names are the
+    ; same as in the legacy mapping but without any enclosing dialplan functions.
+    ; You can specify literals to be placed in the output record by double-quoting
+    ; them. There is also some special notation available in the form of "qualifiers".
+    ; A qualifier is a list of tags, separated by the '^' character and placed
+    ; directly after the field name and enclosed in parentheses.
+    ;
+    ; If you've added user variables to the CDR using the CDR() dialplan function in
+    ; extensions.conf, you'll need to retrieve the field using the "uservar" qualifier.
+    ; For example, if you've added the "ds_type" variable, you'll need to retrieve
+    ; it with `ds_type(uservar)`.
+    ;
+    ; The default output format for the "start", "answer" and "end" timestamp fields
+    ; is the "%Y-%m-%d %T" strftime string format however you can also format those
+    ; fields as an int64 or a float: `start(int64),answer(float),end`.
+    ;
+    ; The "disposition" and "amaflags" are formatted as their string names like
+    ; "ANSWERED" and "DOCUMENTATION" by default but if you just want the numbers and
+    ; not the names...  `amaflags(int64),disposition(int64)`.
+    ;
+    ; If you need to combine flags, use the caret '^' symbol: `start(int64^noquote)`
+    ;
+    ; Final notes about "fields":
+    ;    Field names and qualifiers aren't case sensitive.
+    ;    You MUST use commas to separate the fields.
+    ;    You MUST use double-quotes to indicate literal fields.
+    ;    Whitespace in "fields" is ignored except in literals.
+
+;busy_timeout = 1000    ; The number of milliseconds to wait for a database operation
+                        ; to complete before an error is returned.
diff --git a/configs/samples/cel_custom.conf.sample b/configs/samples/cel_custom.conf.sample
index 3d5b978852..03debc6e29 100644
--- a/configs/samples/cel_custom.conf.sample
+++ b/configs/samples/cel_custom.conf.sample
@@ -1,31 +1,30 @@
 ;
-; Asterisk Channel Event Logging (CEL) - Custom CSV Backend
+; Asterisk Channel Event Logging (CEL) - Custom DSV Backend
 ;
-
-; This is the configuration file for the customizable CSV backend for CEL
+; This is the configuration file for the customizable DSV backend for CEL
 ; logging.
 ;
-; In order to create custom CSV logs for CEL, uncomment the template below
-; (Master.csv) and start Asterisk.  Once CEL events are generated, a file will
-; appear in the following location:
-;
-;                 /var/log/asterisk/cel-custom/Master.csv
+; DSV??  Delimiter Separated Values because the field delimiter doesn't have
+; to be a comma.
 ;
-; (Note that /var/log/asterisk is the default and may differ on your system)
+; Legacy vs Advanced Mappings
 ;
-; You can also create more than one template if desired.  All logs will appear
-; in the cel-custom directory under your Asterisk logs directory.
+; Legacy mappings are those that are defined using dialplan functions like
+; CALLERID and CSV_QUOTE and require a VERY expensive function replacement
+; process at runtime for every record output.  In performance testing, 38%
+; of the CPU instructions executed to handle a call were executed on behalf
+; of legacy CEL logging.  That's more than the instructions actually used
+; to process the call itself.
 ;
-; Alternatively, you can also specify the location of your CSV file using an
-; absolute path, e.g.:
+; Advanced mappings are those that are defined by a list of field names and
+; parameters that define the field separator and quote character you want to
+; use. This type of mapping is uses significantly less resources at runtime.
+; Performance testing showed advanced CEL logging accounting for less than
+; 10% of the CPU instructions executed which is well below the instructions
+; needed to process the call itself.
 ;
-;     /srv/pbx/cel/Master.csv => ${CSV_QUOTE(${eventtype})},...
-;
-
-;
-; Within a mapping, use the CALLERID() and CHANNEL() functions to retrieve
-; details from the CEL event.  There are also a few variables created by this
-; module that can be used in a mapping:
+; There are several "special" variables created by this module that can be used
+; in a mapping, both legacy and advanced:
 ;
 ;    eventtype   - The name of the CEL event.
 ;    eventtime   - The timestamp of the CEL event.
@@ -36,5 +35,218 @@
 ;    BRIDGEPEER  - Bridged peer channel name at the time of the CEL event.
 ;                  CHANNEL(peer) could also be used.
 ;
-[mappings]
+; Legacy Mappings
+;
+; Within a legacy mapping, use the CALLERID(), CHANNEL() and CSV_QUOTE()
+; functions to retrieve values from the CEL event (and pay the price).
+;
+; NOTE: If your legacy mapping uses commas as field separators and only the
+; CSV_QUOTE, CALLERID and CHANNEL dialplan functions or one of the special
+; variables, the module will attempt to strip the functions and create a much
+; faster advanced mapping for it. However, we urge you to create a real
+; advanced mapping and not rely on this process. If the mapping contains
+; something not recognized it will go the slower legacy route.
+;
+; Each entry in the "mappings" category represents a separate output file.
+; A filename that starts with a forward-slash '/' will be treated as an absolute
+; path name.  If it doesn't, it must be a file name with no directory separators
+; which will be placed in the /var/log/asterisk/cel-custom directory.
+;
+;[mappings]
 ;Master.csv => ${CSV_QUOTE(${eventtype})},${CSV_QUOTE(${eventtime})},${CSV_QUOTE(${CALLERID(name)})},${CSV_QUOTE(${CALLERID(num)})},${CSV_QUOTE(${CALLERID(ANI)})},${CSV_QUOTE(${CALLERID(RDNIS)})},${CSV_QUOTE(${CALLERID(DNID)})},${CSV_QUOTE(${CHANNEL(exten)})},${CSV_QUOTE(${CHANNEL(context)})},${CSV_QUOTE(${CHANNEL(channame)})},${CSV_QUOTE(${CHANNEL(appname)})},${CSV_QUOTE(${CHANNEL(appdata)})},${CSV_QUOTE(${CHANNEL(amaflags)})},${CSV_QUOTE(${CHANNEL(accountcode)})},${CSV_QUOTE(${CHANNEL(uniqueid)})},${CSV_QUOTE(${CHANNEL(linkedid)})},${CSV_QUOTE(${BRIDGEPEER})},${CSV_QUOTE(${CHANNEL(userfield)})},${CSV_QUOTE(${userdeftype})},${CSV_QUOTE(${eventextra})}
+
+;
+; Advanced Mappings
+;
+; Each category in this file other than "mappings" represents a separate output file.
+; A filename that starts with a forward-slash '/' will be treated as an absolute
+; path name.  If it doesn't, it must be a file name with no directory separators
+; which will be placed in the /var/log/asterisk/cel-custom directory.
+;
+;[cel_master.csv]             ; Output file or path name.
+
+;format = dsv                 ; Advanced mappings can actually have two types
+                              ; "dsv" or "json".  This example uses "dsv", which
+                              ; is the default, but see the example below for more
+                              ; info on the json format.
+
+;separator_character = ,      ; The character to use for the field separator,
+                              ; It defaults to a comma but you can use any
+                              ; character you want.  For instance, specify
+                              ; \t to separate the fields with a tab.
+
+;quote_character = "          ; The character to use as the quote character.
+                              ; It defaults to the double quote but again, it
+                              ; can be any character you want although the
+                              ; single-quote is the only other one that makes
+                              ; sense.
+
+;quote_escape_character = "   ; The character to use to escape the quote_character
+                              ; should the quote character actually appear in the
+                              ; field output.  A good example of this is a caller
+                              ; id string like "My Name" <1000>.  For true CSV
+                              ; compatibility, it must be the same as the quote
+                              ; character but a backslash might be acceptable for
+                              ; other formats.
+
+;quoting_method = all         ; Nothing says you _have_ to quote anything. The only
+                              ; time a field MUST be quoted is if it contains the
+                              ; separator character and this is handled automatically.
+                              ; Additionally, the following options are available:
+                              ; "all": Quote all fields. The default.
+                              ; "non_numeric": Quote all non numeric fields.
+                              ; "none": Don't quote any field (unless it contains
+                              ; a separator character).
+
+;fields = EventType,eventenum,userdeftype,"Some Literal",EventTime,Name,Num,ani,rdnis,dnid,Exten,Context,ChanName,AppName,AppData,AMAFlags(amaflags),AccountCode,UniqueID(noquote),LinkedID(noquote),Peer,PeerAccount,UserField,EventExtra,TenantID
+    ; This is the list of fields to include in the record. The field names are the
+    ; same as in the legacy mapping but without any enclosing dialplan functions.
+    ; You can specify literals to be placed in the output record by double-quoting
+    ; them. There is also some special notation available in the form of "qualifiers".
+    ; A qualifier is a list of tags, separated by the '^' character and placed
+    ; directly after the field name and enclosed in parentheses.
+    ;
+    ; All fields can accept the "quote" or "noquote" qualifier when you want to exempt
+    ; a field from the "quoting_method" policy.  For example, when quoting_method=all,
+    ; you can force the uniqueid field to not be quoted with `uniqueid(noquote)`. The
+    ; example in fields above shows this.
+    ;
+    ; The default output format for the "EventTime" timestamp field is the "%Y-%m-%d %T"
+    ; strftime string format however you can also format the field as an int64 or a
+    ; float: `eventtime(int64)` or `eventtime(float)`.
+    ;
+    ; Unlike CDRs, the "amaflags" field is output as its numerical value by default
+    ; for historical reasons.  You can output it as its friendly string with
+    ; `amaflags(amaflags)`. This will print "DOCUMENTATION" instead of "3" for instance.
+    ;
+    ; If you need to combine flags, use the caret '^' symbol: `eventtime(int64^noquote)`
+    ;
+    ; Final notes about "fields":
+    ;    Field names and qualifiers aren't case sensitive.
+    ;    You MUST use the comma to separate the fields, not the "separator_character".
+    ;    You MUST use the double-quote to indicate literal fields, not the "quote_character".
+    ;    Whitespace in "fields" is ignored except in literals.
+
+;
+; An Advanced JSON example:
+;
+;[cdr_master.json]
+;format = json
+;fields = EventType,eventenum,userdeftype,"My: Literal",EventTime(float),Name,Num,ani,rdnis,dnid,Exten,Context,ChanName,AppName,AppData,AMAFlags(amaflags),AccountCode,UniqueID,LinkedID,Peer,PeerAccount,UserField,EventExtra,TenantID
+;
+;  In order to ensure valid JSON, the following settings are forced:
+;    The separator character is always the comma.
+;    The quote character is always the double-quote.
+;    The quote escape character is always the backslash.
+;    The quoting method is always "non_numeric".
+;
+;  Since JSON requires both a name and value, the name is always
+;  the field name.  For literals however you must specify the literal
+;  as a "name: value" pair as demonstrated above.  The output record
+;  would then look something like:
+; {"eventtype":"HANGUP","eventenum":"HANGUP","userdeftype":"","My":"Literal","eventtime":1771359872.0, ...}
+
+
+
+
+
+
+
+
+
+
+;
+; Advanced Mappings
+;
+; Advanced mappings use SIGNIFICANTLY less resources than legacy mappings
+; because we don't need to use dialplan function replacement.
+;
+;[cel_master_advanced.csv]    ; The destination file name.
+                              ; Can be a name relative to ASTLOGDIR or an
+                              ; absolute path.
+
+;format = csv                 ; Sets the output format.  The default is "csv"
+                              ; but here "csv" really means "character-separated-values"
+                              ; because the separator doesn't have to be a comma.
+                              ; The other alternative is "json" (see example below).
+
+;separator_character = ,      ; Set the character to use between fields.
+                              ; Defaults to a comma but other characters
+                              ; can be used.  For example, if you want
+                              ; tab-separated fields, use \t as the separator.
+
+;quote_character = "          ; Set the quoting character.
+                              ; Defaults to double-quote (") but any character
+                              ; can be used although only the double and single
+                              ; quote (') characters make sense.
+
+;quote_escape_character = "   ; Sets the character used to escape quotes that
+                              ; may be in the field values. The default is the
+                              ; same character as the quote_character so an
+                              ; embedded JSON blob would look like this:
+                              ; "{""extra"":""somextratext""}"
+                              ; You could also use a backslash (\) in which case
+                              ; the blob would look like this:
+                              ; "{\"extra\":\"somextratext\"}"
+
+;quoting_method = all         ; Sets what/when to quote.
+                              ; all - Quote all fields. (the default)
+                              ; minimal - Only quote fields that have the
+                              ;           separator character in them.
+                              ; non_numeric - Quote all non-numeric fields.
+                              ; none - Don't quote anything.
+                              ;        Probably not a good idea but could
+                              ;        be useful in special circumstances.
+
+; The fields to output.  These names correspond to the internal CEL event
+; field names which is how some of the performance gains are realized.
+; Anything not recognized as a field name will be printed as a literal in
+; the output.
+;
+; CEL Event Field Names:
+;
+; eventtype - Could be a standard event name or a user event name
+; eventenum - Will always be a standard event name or 'USER_DEFINED'
+; eventtime - Uses the dateformat set in cel.conf.
+; usereventname - Will be the user event name if set.
+; cidname
+; cidnum
+; exten
+; context
+; channame
+; appname
+; appdata
+; amaflags
+; acctcode
+; uniqueid
+; userfield
+; cidani
+; cidrdnis
+; ciddnid
+; peer
+; linkedid
+; peeracct
+; extra
+; tenantid
+;
+; You MUST use the comma and double-quote here, not the separator or
+; or quote characters specified above.  The names are case-insensitive.
+;
+;fields = EventType,EventEnum,"My Literal",EventTime,UserEventName,CIDName,CIDNum,Exten,Context,ChanName,AppName,AppData,AMAFlags,AcctCode,UniqueID,UserField,CIDani,CIDrdnis,CIDdnid,Peer,LinkedID,PeerAcct,Extra,TenantID
+
+;
+;  A tab-separated-value example:
+;
+;[cel_master.tsv]
+;separator_character = \t
+;fields = EventType,EventEnum,"My Literal",EventTime,UserEventName,CIDName,CIDNum,Exten,Context,ChanName,AppName,AppData,AMAFlags,AcctCode,UniqueID,UserField,CIDani,CIDrdnis,CIDdnid,Peer,LinkedID,PeerAcct,Extra,TenantID
+
+;
+;  A JSON example:
+;
+; The separator and quoting options don't apply to JSON.
+; Literals must be specified as a "name: value" pair or they'll be ignored.
+;
+;[cel_master.json]
+;format = json
+;fields = EventType,eventenum,userdeftype,"My: Literal",EventTime,CIDName,CIDNum,CIDani,CIDrdnis,CIDdnid,Exten,Context,ChanName,AppName,AppData,AMAFlags,AcctCode,UniqueID,LinkedID,Peer,PeerAcct,UserField,Extra,TenantID
diff --git a/configs/samples/cel_sqlite3_custom.conf.sample b/configs/samples/cel_sqlite3_custom.conf.sample
index aa908a4f36..0d549b280a 100644
--- a/configs/samples/cel_sqlite3_custom.conf.sample
+++ b/configs/samples/cel_sqlite3_custom.conf.sample
@@ -1,13 +1,33 @@
 ;
-; Asterisk Channel Event Logging (CEL) - SQLite 3 Backend
+; Asterisk Channel Event Logging (CEL) - Custom Sqlite3 Backend
 ;

+; This is the configuration file for the customizable Sqlite3 backend for CEL
+; logging.
 ;
-; Mappings for sqlite3 config file
+; Legacy vs Advanced Mappings
 ;
-; Within a mapping, use the CALLERID() and CHANNEL() functions to retrieve
-; details from the CEL event.  There are also a few variables created by this
-; module that can be used in a mapping:
+; Legacy mappings are those that are defined using dialplan functions like
+; CALLERID and CHANNEL and require a VERY expensive function replacement
+; process at runtime for every record output.
+;
+; Advanced mappings are those that are defined by a simple list of field names
+; and uses significantly less resources at runtime.
+;
+;
+; Each category in this file represents a separate Sqlite3 database file.
+; A filename that starts with a forward-slash '/' will be treated as an absolute
+; path name.  If it doesn't, it must be a file name with no directory separators
+; which will be placed in the /var/log/asterisk directory.  If the database
+; file doesn't already exist, it will be created.
+;
+; Previous versions of Asterisk limited the output to a single database file
+; named "master" which was shared with CDR Sqlite3 logging.  That is no longer
+; the case.
+;
+;
+; There are several "special" variables created by this module that can be used
+; in a mapping, both legacy and advanced:
 ;
 ;    eventtype   - The name of the CEL event.
 ;    eventtime   - The timestamp of the CEL event.
@@ -18,8 +38,79 @@
 ;    BRIDGEPEER  - Bridged peer channel name at the time of the CEL event.
 ;                  CHANNEL(peer) could also be used.
 ;
-;[master] ; currently, only file "master.db" is supported, with only one table at a time.
-;table	=> cel
-;columns	=> eventtype, eventtime, cidname, cidnum, cidani, cidrdnis, ciddnid, context, exten, channame, appname, appdata, amaflags, accountcode, uniqueid, userfield, peer, userdeftype, eventextra
-;values	=> '${eventtype}','${eventtime}','${CALLERID(name)}','${CALLERID(num)}','${CALLERID(ANI)}','${CALLERID(RDNIS)}','${CALLERID(DNID)}','${CHANNEL(context)}','${CHANNEL(exten)}','${CHANNEL(channame)}','${CHANNEL(appname)}','${CHANNEL(appdata)}','${CHANNEL(amaflags)}','${CHANNEL(accountcode)}','${CHANNEL(uniqueid)}','${CHANNEL(userfield)}','${BRIDGEPEER}','${userdeftype}','${eventextra}'
-;busy_timeout	=> 1000
\ No newline at end of file
+; Legacy Mappings
+;
+; Within a legacy mapping, use the CALLERID() and CHANNEL() functions or
+; the special variables above to retrieve values from the CEL event.
+;
+; NOTE: If your legacy mapping uses only those two functions and the special
+; variables, the module will attempt to strip the functions and create a much
+; faster advanced mapping for it. However, we urge you to create a real advanced
+; mapping and not rely on this process. If the mapping contains something not
+; recognized it will go the slower legacy route.
+;
+;
+;[cel_master]
+;table = cel             ; The name of the table in the database into which records
+                         ; are to be written.  If the table doesn't already exist,
+                         ; it will be created.
+
+;columns = eventtype, eventtime, cidname, cidnum, cidani, cidrdnis, ciddnid, context, exten, channame, appname, appdata, amaflags, accountcode, uniqueid, userfield, peer, userdeftype, eventextra
+    ; The column names to receive the fields.  If the table doesn't already exist,
+    ; it will be created with these columns.  If the table does exist, this list
+    ; MUST match the existing columns or the config will fail to load.
+    ; The column names do NOT have to match the field names however.
+
+values = '${eventtype}','${eventtime}','${CALLERID(name)}','${CALLERID(num)}','${CALLERID(ANI)}','${CALLERID(RDNIS)}','${CALLERID(DNID)}','${CHANNEL(context)}','${CHANNEL(exten)}','${CHANNEL(channame)}','${CHANNEL(appname)}','${CHANNEL(appdata)}','${CHANNEL(amaflags)}','${CHANNEL(accountcode)}','${CHANNEL(uniqueid)}','${CHANNEL(userfield)}','${BRIDGEPEER}','${userdeftype}','${eventextra}'
+    ; The list of fields to write into the columns.
+    ; Each field MUST be enclosed in single-quotes and the fields separated
+    ; by commas.  Additionally, the number of fields specified MUST match the
+    ; number of columns or the config will fail to load.
+
+;busy_timeout = 1000    ; The number of milliseconds to wait for a database operation
+                        ; to complete before an error is returned.
+
+;
+; Advanced Mappings
+;
+;[cdr_advanced]
+;table = cel             ; The name of the table in the database into which records
+                         ; are to be written.  If the table doesn't already exist,
+                         ; it will be created.
+
+;columns = eventtype, literal, eventtime, cidname, cidnum, cidani, cidrdnis, ciddnid, context, exten, channame, appname, appdata, amaflags, accountcode, uniqueid, userfield, peer, userdeftype, eventextra
+    ; The column names to receive the fields.  If the table doesn't already exist,
+    ; it will be created with these columns.  If the table does exist, this list
+    ; MUST match the existing columns or the config will fail to load.
+    ; The column names do NOT have to match the field names however.
+
+;fields = eventtype, "some literal", eventtime, name, num, ani, rdnis, dnid, context, exten, channame, appname, appdata, amaflags, accountcode, uniqueid, userfield, peer, userdeftype, eventextra
+    ; The "fields" parameter differentiates this mapping as an Advanced one
+    ; as opposed to "values" used above.
+    ;
+    ; This is the list of fields to include in the record. The field names are the
+    ; same as in the legacy mapping but without any enclosing dialplan functions or
+    ; quotes. You can specify literals to be placed in the output record by
+    ; double-quoting them. There is also some special notation available in the
+    ; form of "qualifiers". A qualifier is a list of tags, separated by the '^'
+    ; character and placed directly after the field name and enclosed in parentheses.
+    ;
+    ; The default output format for the "EventTime" timestamp field is the "%Y-%m-%d %T"
+    ; strftime string format however you can also format the field as an int64 or a
+    ; float: `eventtime(int64)` or `eventtime(float)`.
+    ;
+    ; Unlike CDRs, the "amaflags" field is output as its numerical value by default
+    ; for historical reasons.  You can output it as its friendly string with
+    ; `amaflags(amaflags)`. This will print "DOCUMENTATION" instead of "3" for instance.
+    ;
+    ; If you need to combine flags, use the caret '^' symbol: `eventtime(int64^noquote)`
+    ;
+    ; Final notes about "fields":
+    ;    Field names and qualifiers aren't case sensitive.
+    ;    You MUST use commas to separate the fields.
+    ;    You MUST use double-quotes to indicate literal fields.
+    ;    Whitespace in "fields" is ignored except in literals.
+
+;busy_timeout = 1000    ; The number of milliseconds to wait for a database operation
+                        ; to complete before an error is returned.
+
diff --git a/include/asterisk/cel.h b/include/asterisk/cel.h
index 8a1e8b884f..fd993910af 100644
--- a/include/asterisk/cel.h
+++ b/include/asterisk/cel.h
@@ -34,6 +34,7 @@ extern "C" {
 #endif

 #include "asterisk/event.h"
+#include "asterisk/strings.h"

 /*!
  * \brief CEL event types
@@ -117,6 +118,18 @@ const char *ast_cel_get_type_name(enum ast_cel_event_type type);
  */
 enum ast_cel_event_type ast_cel_str_to_event_type(const char *name);

+/*!
+ * \brief Format an event timeval using dateformat from cel.conf
+ *
+ * \param eventtime The timeval to format
+ * \param timebuf A buffer of at least 30 characters to place the result in
+ * \param len Length of buffer
+
+ * \retval zero Success
+ * \retval non-zero Failure
+ */
+int ast_cel_format_eventtime(struct timeval eventtime, char *timebuf, size_t len);
+
 /*!
  * \brief Create a fake channel from data in a CEL event
  *
diff --git a/include/asterisk/res_cdrel_custom.h b/include/asterisk/res_cdrel_custom.h
new file mode 100644
index 0000000000..8dcc89ba3d
--- /dev/null
+++ b/include/asterisk/res_cdrel_custom.h
@@ -0,0 +1,117 @@
+/*
+ * Asterisk -- An open source telephony toolkit.
+ *
+ * Copyright (C) 2026, Sangoma Technologies Corporation
+ *
+ * George Joseph <gjoseph@sangoma.com>
+ *
+ * See http://www.asterisk.org for more information about
+ * the Asterisk project. Please do not directly contact
+ * any of the maintainers of this project for assistance;
+ * the project provides a web site, mailing lists and IRC
+ * channels for your use.
+ *
+ * This program is free software, distributed under the terms of
+ * the GNU General Public License Version 2. See the LICENSE file
+ * at the top of the source tree.
+ */
+
+/*!
+ * \file
+ * \author George Joseph <gjoseph@sangoma.com>
+ *
+ * \brief Protected header for the CDR and CEL Custom Backends
+ *
+ * \warning This file should be included only by CDR and CEL backends.
+ *
+ */
+
+#ifndef _RES_CDREL_CUSTOM_H
+#define _RES_CDREL_CUSTOM_H
+
+/*! \enum Backend Types */
+enum cdrel_backend_type {
+	cdrel_backend_text = 0, /*!< Text file: DSV or JSON */
+	cdrel_backend_db,       /*!< Database (currently only sqlite3) */
+	cdrel_backend_type_end, /*!< Sentinel */
+};
+
+/*! \enum Record Types */
+enum cdrel_record_type {
+	cdrel_record_cdr = 0,   /*!< Call Detail Records */
+	cdrel_record_cel,       /*!< Channel Event Log records */
+	cdrel_record_type_end,  /*!< Sentinel */
+};
+
+/*! \struct Forward declaration of a configuration */
+struct cdrel_config;
+
+/*! \struct Vector to hold all configurations in a config file */
+AST_VECTOR(cdrel_configs, struct cdrel_config *);
+
+/*!
+ * \brief Perform initial module load.
+ *
+ * Needs to be called by each "custom" module
+ *
+ * \param backend_type One of \ref cdrel_backend_type.
+ * \param record_type One of \ref cdrel_record_type.
+ * \param config_filename The config file name.
+ * \param backend_name The name to register the backend as.
+ * \param logging_cb The logging callback to register with CDR or CEL.
+ * \returns A pointer to a VECTOR or config objects read from the config file.
+ */
+struct cdrel_configs *cdrel_load_module(enum cdrel_backend_type backend_type,
+	enum cdrel_record_type record_type, const char *config_filename,
+	const char *backend_name, void *logging_cb);
+
+/*!
+ * \brief Perform module reload.
+ *
+ * Needs to be called by each "custom" module
+ *
+ * \warning This function MUST be called with the module's config_lock held
+ * for writing to prevent reloads from happening while we're logging.
+ *
+ * \param backend_type One of \ref cdrel_backend_type.
+ * \param record_type One of \ref cdrel_record_type.
+ * \param configs A pointer to the VECTOR of config objects returned by \ref cdrel_load_module.
+ * \param config_filename The config file name.
+ * \retval AST_MODULE_LOAD_SUCCESS
+ * \retval AST_MODULE_LOAD_DECLINE
+ */
+int cdrel_reload_module(enum cdrel_backend_type backend_type, enum cdrel_record_type record_type,
+	struct cdrel_configs **configs, const char *config_filename);
+
+/*!
+ * \brief Perform module unload.
+ *
+ * Needs to be called by each "custom" module
+ *
+ * \warning This function MUST be called with the module's config_lock held
+ * for writing to prevent the module from being unloaded while we're logging.
+ *
+ * \param backend_type One of \ref cdrel_backend_type.
+ * \param record_type One of \ref cdrel_record_type.
+ * \param configs A pointer to the VECTOR of config objects returned by \ref cdrel_load_module.
+ * \param backend_name The backend name to unregister.
+ * \retval 0 Success.
+ * \retval -1 Failure.
+ */
+int cdrel_unload_module(enum cdrel_backend_type backend_type, enum cdrel_record_type record_type,
+	struct cdrel_configs *configs, const char *backend_name);
+
+/*!
+ * \brief Log a record. The module's \ref logging_cb must call this.
+ *
+ * \warning This function MUST be called with the module's config_lock held
+ * for reading to prevent reloads from happening while we're logging.
+ *
+ * \param configs A pointer to the VECTOR of config objects returned by \ref cdrel_load_module.
+ * \param data A pointer to an ast_cdr or ast_event object to log.
+ * \retval 0 Success.
+ * \retval -1 Failure.
+ */
+int cdrel_logger(struct cdrel_configs *configs, void *data);
+
+#endif /* _RES_CDREL_CUSTOM_H */
diff --git a/main/cel.c b/main/cel.c
index 4e99f63242..251041a90b 100644
--- a/main/cel.c
+++ b/main/cel.c
@@ -673,6 +673,39 @@ static void check_retire_linkedid(struct ast_channel_snapshot *snapshot, const s
 	ao2_ref(lid, -1);
 }

+static int cel_format_eventtime(struct cel_config *cfg, struct timeval eventtime, char *timebuf, size_t len)
+{
+	if (!timebuf || len < 30) {
+		return -1;
+	}
+
+	if (ast_strlen_zero(cfg->general->date_format)) {
+		snprintf(timebuf, len, "%ld.%06ld", (long) eventtime.tv_sec,
+				(long) eventtime.tv_usec);
+	} else {
+		struct ast_tm tm;
+		ast_localtime(&eventtime, &tm, NULL);
+		ast_strftime(timebuf, len, cfg->general->date_format, &tm);
+	}
+
+	return 0;
+}
+
+int ast_cel_format_eventtime(struct timeval eventtime, char *timebuf, size_t len)
+{
+	struct cel_config *cfg = ao2_global_obj_ref(cel_configs);
+	int res = 0;
+
+	if (!cfg) {
+		return -1;
+	}
+
+	res = cel_format_eventtime(cfg, eventtime, timebuf, len);
+	ao2_cleanup(cfg);
+
+	return res;
+}
+
 /* Note that no 'chan_fixup' function is provided for this datastore type,
  * because the channels that will use it will never be involved in masquerades.
  */
@@ -719,14 +752,7 @@ struct ast_channel *ast_cel_fabricate_channel_from_event(const struct ast_event
 		AST_LIST_INSERT_HEAD(headp, newvariable, entries);
 	}

-	if (ast_strlen_zero(cfg->general->date_format)) {
-		snprintf(timebuf, sizeof(timebuf), "%ld.%06ld", (long) record.event_time.tv_sec,
-				(long) record.event_time.tv_usec);
-	} else {
-		struct ast_tm tm;
-		ast_localtime(&record.event_time, &tm, NULL);
-		ast_strftime(timebuf, sizeof(timebuf), cfg->general->date_format, &tm);
-	}
+	cel_format_eventtime(cfg, record.event_time, timebuf, sizeof(timebuf));

 	if ((newvariable = ast_var_assign("eventtime", timebuf))) {
 		AST_LIST_INSERT_HEAD(headp, newvariable, entries);
@@ -759,6 +785,7 @@ struct ast_channel *ast_cel_fabricate_channel_from_event(const struct ast_event
 	ast_channel_accountcode_set(tchan, record.account_code);
 	ast_channel_peeraccount_set(tchan, record.peer_account);
 	ast_channel_userfield_set(tchan, record.user_field);
+	ast_channel_tenantid_set(tchan, record.tenant_id);

 	if ((newvariable = ast_var_assign("BRIDGEPEER", record.peer))) {
 		AST_LIST_INSERT_HEAD(headp, newvariable, entries);
diff --git a/res/Makefile b/res/Makefile
index 722b93d7db..2bbd0bd689 100644
--- a/res/Makefile
+++ b/res/Makefile
@@ -69,6 +69,7 @@ $(call MOD_ADD_C,res_stasis_recording,stasis_recording/stored.c)
 $(call MOD_ADD_C,res_stir_shaken,$(wildcard res_stir_shaken/*.c))
 $(call MOD_ADD_C,res_aeap,$(wildcard res_aeap/*.c))
 $(call MOD_ADD_C,res_geolocation,$(wildcard res_geolocation/*.c))
+$(call MOD_ADD_C,res_cdrel_custom,$(wildcard cdrel_custom/*.c))

 # These are the xml and xslt files to be embedded
 res_geolocation.so: res_geolocation/pidf_lo_test.o res_geolocation/pidf_to_eprofile.o res_geolocation/eprofile_to_pidf.o
diff --git a/res/cdrel_custom/cdrel.h b/res/cdrel_custom/cdrel.h
new file mode 100644
index 0000000000..84787840e9
--- /dev/null
+++ b/res/cdrel_custom/cdrel.h
@@ -0,0 +1,363 @@
+/*
+ * Asterisk -- An open source telephony toolkit.
+ *
+ * Copyright (C) 2026, Sangoma Technologies Corporation
+ *
+ * George Joseph <gjoseph@sangoma.com>
+ *
+ * See http://www.asterisk.org for more information about
+ * the Asterisk project. Please do not directly contact
+ * any of the maintainers of this project for assistance;
+ * the project provides a web site, mailing lists and IRC
+ * channels for your use.
+ *
+ * This program is free software, distributed under the terms of
+ * the GNU General Public License Version 2. See the LICENSE file
+ * at the top of the source tree.
+ */
+
+/*!
+ * \file
+ * \author George Joseph <gjoseph@sangoma.com>
+ *
+ * \brief Private header for res_cdrel_custom.
+ *
+ */
+
+#ifndef _CDREL_H
+#define _CDREL_H
+
+#include <sqlite3.h>
+
+#include "asterisk.h"
+#include "asterisk/cdr.h"
+#include "asterisk/cel.h"
+#include "asterisk/event.h"
+#include "asterisk/lock.h"
+#include "asterisk/strings.h"
+#include "asterisk/vector.h"
+#include "asterisk/res_cdrel_custom.h"
+
+extern const char *cdrel_record_type_map[];
+#define RECORD_TYPE_STR(_rt) (cdrel_record_type_map[_rt])
+
+extern const char *cdrel_module_type_map[];
+#define MODULE_TYPE_STR(_mt) (cdrel_module_type_map[_mt])
+
+enum cdrel_text_format_type {
+	cdrel_format_dsv = 0,
+	cdrel_format_json,
+	cdrel_format_sql,
+	cdrel_format_type_end,
+};
+
+enum cdrel_config_type {
+	cdrel_config_legacy = 0,
+	cdrel_config_advanced,
+	cdrel_config_type_end,
+};
+
+enum cdrel_quoting_method {
+	cdrel_quoting_method_none = 0,
+	cdrel_quoting_method_all,
+	cdrel_quoting_method_minimal,
+	cdrel_quoting_method_non_numeric,
+	cdrel_quoting_method_end,
+};
+
+/*
+ * ORDER IS IMPORTANT!
+ * The string output data types need to be first.
+ */
+enum cdrel_data_type {
+	cdrel_type_string = 0,
+	cdrel_type_timeval,
+	cdrel_type_literal,
+	cdrel_type_amaflags,
+	cdrel_type_disposition,
+	cdrel_type_uservar,
+	cdrel_type_event_type,
+	cdrel_type_event_enum,
+	cdrel_data_type_strings_end,
+	cdrel_type_int32,
+	cdrel_type_uint32,
+	cdrel_type_int64,
+	cdrel_type_uint64,
+	cdrel_type_float,
+	cdrel_data_type_end
+};
+
+extern const char *cdrel_data_type_map[];
+#define DATA_TYPE_STR(_dt) (_dt < cdrel_data_type_end ? cdrel_data_type_map[_dt] : NULL)
+enum cdrel_data_type cdrel_data_type_from_str(const char *str);
+
+#define CDREL_FIELD_FLAG_QUOTE (0)
+#define CDREL_FIELD_FLAG_NOQUOTE (1)
+#define CDREL_FIELD_FLAG_TYPE_FORCED (2)
+#define CDREL_FIELD_FLAG_USERVAR (3)
+#define CDREL_FIELD_FLAG_LITERAL (4)
+#define CDREL_FIELD_FLAG_FORMAT_SPEC (5)
+#define CDREL_FIELD_FLAG_LAST (6)
+
+enum cdrel_field_flags {
+	cdrel_flag_quote = (1 << CDREL_FIELD_FLAG_QUOTE),
+	cdrel_flag_noquote = (1 << CDREL_FIELD_FLAG_NOQUOTE),
+	cdrel_flag_type_forced = (1 << CDREL_FIELD_FLAG_TYPE_FORCED),
+	cdrel_flag_uservar = (1 << CDREL_FIELD_FLAG_USERVAR),
+	cdrel_flag_literal = (1 << CDREL_FIELD_FLAG_LITERAL),
+	cdrel_flag_format_spec = (1 << CDREL_FIELD_FLAG_FORMAT_SPEC),
+	cdrel_field_flags_end = (1 << CDREL_FIELD_FLAG_LAST),
+};
+
+/*
+ * CEL has a few synthetic fields that aren't defined
+ * in event.h so we'll define them ourselves after the
+ * last official id.
+ */
+#define AST_EVENT_IE_CEL_LITERAL (AST_EVENT_IE_TOTAL + 1)
+#define AST_EVENT_IE_CEL_EVENT_ENUM (AST_EVENT_IE_TOTAL + 2)
+#define LAST_CEL_ID AST_EVENT_IE_CEL_EVENT_ENUM
+
+/*!
+ * While CEL event fields are published as a generic AST_EVENT with
+ * a field id assigned to each field, CDRs are published as a fixed
+ * ast_cdr structure.  To make it easier to share lower level code,
+ * we assign pseudo-field-ids to each CDR field that are really the offset
+ * of the field in the structure.  This allows us to generically get
+ * any field using its id just like we do for CEL.
+ *
+ * To avoid conflicts with the existing CEL field ids, we'll start these
+ * after the last one.
+ */
+#define CDR_OFFSET_SHIFT (LAST_CEL_ID + 1)
+#define CDR_OFFSETOF(_field) (offsetof(struct ast_cdr, _field) + CDR_OFFSET_SHIFT)
+#define CDR_FIELD(__cdr, __offset) (((void *)__cdr) + __offset - CDR_OFFSET_SHIFT)
+
+enum cdr_field_id {
+	cdr_field_literal = -1,
+	cdr_field_clid = CDR_OFFSETOF(clid),
+	cdr_field_src = CDR_OFFSETOF(src),
+	cdr_field_dst = CDR_OFFSETOF(dst),
+	cdr_field_dcontext = CDR_OFFSETOF(dcontext),
+	cdr_field_channel = CDR_OFFSETOF(channel),
+	cdr_field_dstchannel = CDR_OFFSETOF(dstchannel),
+	cdr_field_lastapp = CDR_OFFSETOF(lastapp),
+	cdr_field_lastdata = CDR_OFFSETOF(lastdata),
+	cdr_field_start = CDR_OFFSETOF(start),
+	cdr_field_answer = CDR_OFFSETOF(answer),
+	cdr_field_end = CDR_OFFSETOF(end),
+	cdr_field_duration = CDR_OFFSETOF(duration),
+	cdr_field_billsec = CDR_OFFSETOF(billsec),
+	cdr_field_disposition = CDR_OFFSETOF(disposition),
+	cdr_field_amaflags = CDR_OFFSETOF(amaflags),
+	cdr_field_accountcode = CDR_OFFSETOF(accountcode),
+	cdr_field_peeraccount = CDR_OFFSETOF(peeraccount),
+	cdr_field_flags = CDR_OFFSETOF(flags),
+	cdr_field_uniqueid = CDR_OFFSETOF(uniqueid),
+	cdr_field_linkedid = CDR_OFFSETOF(linkedid),
+	cdr_field_tenantid = CDR_OFFSETOF(tenantid),
+	cdr_field_peertenantid = CDR_OFFSETOF(peertenantid),
+	cdr_field_userfield = CDR_OFFSETOF(userfield),
+	cdr_field_sequence = CDR_OFFSETOF(sequence),
+	cdr_field_varshead = CDR_OFFSETOF(varshead),
+};
+
+
+struct cdrel_field;
+
+/*!
+ * \internal
+ * \brief A generic value wrapper structure.
+ */
+struct cdrel_value {
+	char *field_name;
+	enum cdrel_data_type data_type;
+	int mallocd;
+	union {
+		char *string;
+		int32_t int32;
+		uint32_t uint32;
+		int64_t int64;
+		uint64_t uint64;
+		struct timeval tv;
+		float floater;
+	} values;
+};
+
+/*!
+ * \internal
+ * \brief A vector of cdrel_values.
+ */
+AST_VECTOR(cdrel_values, struct cdrel_value *);
+
+/*!
+ * \internal
+ * \brief Getter callbacks.
+ *
+ * \param record An ast_cdr or ast_event structure.
+ * \param config Config object.
+ * \param field Field object.
+ * \param value A pointer to a cdrel_value structure to populate.
+ * \retval 0 on success.
+ * \retval -1 on failure.
+ */
+typedef int (*cdrel_field_getter)(void *record, struct cdrel_config *config,
+	struct cdrel_field *field, struct cdrel_value *value);
+/*!
+ * \internal
+ * \brief The table of getter callbacks.  Populated by getters_cdr.c and getters_cel.c.
+ *
+ * Defined in res_cdrel_custom.c
+ */
+extern cdrel_field_getter cdrel_field_getters[cdrel_record_type_end][cdrel_data_type_end];
+
+/*!
+ * \internal
+ * \brief Data type formatters.
+ *
+ * \param config Config object.
+ * \param field Field object.
+ * \param input_value A pointer to a cdrel_value structure with the data to format.
+ * \param output_value A pointer to a cdrel_value structure to receive the formatted data.
+ * \retval 0 on success.
+ * \retval -1 on failure.
+ */
+typedef int (*cdrel_field_formatter)(struct cdrel_config *config,
+	struct cdrel_field *field, struct cdrel_value *input_value, struct cdrel_value *output_value);
+/*!
+ * \internal
+ * \brief The table of formatter callbacks.  Populated by formatters.c.
+ *
+ * Defined in res_cdrel_custom.c
+ */
+extern cdrel_field_formatter cdrel_field_formatters[cdrel_data_type_end];
+
+/*!
+ * \internal
+ * \brief Backend writers.
+ *
+ * \param config Config object.
+ * \param values A vector of cdrel_values to write.
+ * \retval 0 on success.
+ * \retval -1 on failure.
+ */
+typedef int (*cdrel_backend_writer)(struct cdrel_config *config, struct cdrel_values *values);
+/*!
+ * \internal
+ * \brief The table of writer callbacks.  Populated by writers.c.
+ *
+ * Defined in res_cdrel_custom.c
+ */
+extern cdrel_backend_writer cdrel_backend_writers[cdrel_format_type_end];
+
+/*!
+ * \internal
+ * \brief Dummy channel allocators.
+ *
+ * Legacy configurations use dialplan functions so they need a dummy channel
+ * to operate on. CDR and CEL each have their own allocator.
+ *
+ * \param config Config object.
+ * \param record An ast_cdr or ast_event structure.
+ * \return an ast_channel or NULL on failure.
+ */
+typedef struct ast_channel * (*cdrel_dummy_channel_alloc)(struct cdrel_config *config, void *record);
+extern cdrel_dummy_channel_alloc cdrel_dummy_channel_allocators[cdrel_format_type_end];
+
+
+/*! Represents a field definition */
+struct cdrel_field {
+	enum cdrel_record_type record_type;     /*!< CDR or CEL */
+	int field_id;                           /*!< May be an AST_EVENT_IE_CEL or a cdr_field_id */
+	char *name;                             /*!< The name of the field */
+	enum cdrel_data_type input_data_type;   /*!< The data type of the field in the source record */
+	enum cdrel_data_type output_data_type;
+	struct ast_flags flags;                 /*!< Flags used during config parsing */
+	char data[1];                           /*!< Could be literal data, a user variable name, etc */
+};
+
+/*! Represents an output definition from a config file */
+struct cdrel_config {
+	enum cdrel_record_type record_type;            /*!< CDR or CEL */
+	AST_DECLARE_STRING_FIELDS(
+		AST_STRING_FIELD(config_filename);         /*!< Input configuration filename */
+		AST_STRING_FIELD(output_filename);         /*!< Output text file or database */
+		AST_STRING_FIELD(template);                /*!< Input template */
+		AST_STRING_FIELD(db_columns);              /*!< List of columns for database backends */
+		AST_STRING_FIELD(db_table);                /*!< Table name for database backends */
+	);
+	sqlite3 *db;                                   /*!< sqlite3 database handle */
+	sqlite3_stmt *insert;                          /*!< sqlite3 prepared statement for insert */
+	int busy_timeout;                              /*!< sqlite3 query timeout value */
+	cdrel_dummy_channel_alloc dummy_channel_alloc; /*!< Legacy config types need a dummy channel */
+	enum cdrel_backend_type backend_type;          /*!< Text file or database */
+	enum cdrel_config_type config_type;            /*!< Legacy or advanced */
+	enum cdrel_text_format_type format_type;       /*!< For text files, CSV or JSON */
+	enum cdrel_quoting_method quoting_method;      /*!< When to quote */
+	char separator[2];                             /*!< For text files, the field separator */
+	char quote[2];                                 /*!< For text files, the quote character */
+	char quote_escape[2];                          /*!< For text files, character to use to escape embedded quotes */
+	AST_VECTOR(, struct cdrel_field *) fields;     /*!< Vector of fields for this config */
+	ast_mutex_t lock;                              /*!< Lock that serializes filesystem writes */
+};
+
+/*!
+ * \internal
+ * \brief Get a cdrel_field structure by record type and field name.
+ *
+ * \param record_type The cdrel_record_type to search.
+ * \param name The field name to search for.
+ * \returns A pointer to a constant cdrel_field structure or NULL if not found.
+ *          This pointer must never be freed.
+ */
+const struct cdrel_field *get_registered_field_by_name(enum cdrel_record_type record_type,
+	const char *name);
+
+/*!
+ * \internal
+ * \brief Write a record to a text file
+ *
+ * \param config The configuration object.
+ * \param record The data to write.
+ * \retval 0 on success
+ * \retval -1 on failure
+ */
+int write_record_to_file(struct cdrel_config *config, struct ast_str *record);
+
+/*!
+ * \internal
+ * \brief Write a record to a database
+ *
+ * \param config The configuration object.
+ * \param values The values to write.
+ * \retval 0 on success
+ * \retval -1 on failure
+ */
+int write_record_to_database(struct cdrel_config *config, struct cdrel_values *values);
+
+/*!
+ * \internal
+ * \brief Return the basename of a path
+ *
+ * \param path
+ * \returns Pointer to last '/' or path if no '/' was found.
+ */
+const char *cdrel_basename(const char *path);
+
+/*!
+ * \internal
+ * \brief Get a string representing a field's flags
+ *
+ * \param flags An ast_flags structure
+ * \param str Pointer to ast_str* buffer
+ * \returns A string of field flag names
+ */
+const char *cdrel_get_field_flags(struct ast_flags *flags, struct ast_str **str);
+
+
+int load_cdr(void);
+int load_cel(void);
+int load_formatters(void);
+int load_writers(void);
+
+#endif /* _CDREL_H */
diff --git a/res/cdrel_custom/config.c b/res/cdrel_custom/config.c
new file mode 100644
index 0000000000..d4655f0792
--- /dev/null
+++ b/res/cdrel_custom/config.c
@@ -0,0 +1,1458 @@
+/*
+ * Asterisk -- An open source telephony toolkit.
+ *
+ * Copyright (C) 2026, Sangoma Technologies Corporation
+ *
+ * George Joseph <gjoseph@sangoma.com>
+ *
+ * See http://www.asterisk.org for more information about
+ * the Asterisk project. Please do not directly contact
+ * any of the maintainers of this project for assistance;
+ * the project provides a web site, mailing lists and IRC
+ * channels for your use.
+ *
+ * This program is free software, distributed under the terms of
+ * the GNU General Public License Version 2. See the LICENSE file
+ * at the top of the source tree.
+ */
+
+/*!
+ * \file
+ * \author George Joseph <gjoseph@sangoma.com>
+ *
+ * \brief Common config file handling for res_cdrel_custom.
+ *
+ * This file is a 'bit' complex.  The reasoning is that the functions
+ * do as much work as possible at module load time to reduce the workload
+ * at run time.
+ *
+ */
+
+#include "cdrel.h"
+
+#include "asterisk/config.h"
+#include "asterisk/module.h"
+#include "asterisk/paths.h"
+
+/*
+ * The DSV files get placed in specific subdirectories
+ * while the SQL databases get placed directly in /var/log/asterisk.
+ */
+static const char *dirname_map[cdrel_backend_type_end][cdrel_record_type_end] = {
+	[cdrel_backend_text] = {
+		[cdrel_record_cdr] = "cdr-custom",
+		[cdrel_record_cel] = "cel-custom",
+	},
+	[cdrel_backend_db] = {
+		[cdrel_record_cdr] = NULL,
+		[cdrel_record_cel] = NULL,
+	}
+};
+
+struct field_parse_result {
+	char *result;
+	int functions;
+	int csv_quote;
+	int cdr;
+	int is_literal;
+	int unknown_functions;
+	int parse_failed;
+};
+
+/*
+ * To maximize the possibility that we can put a legacy config through the
+ * much faster advanced process, we need to ensure that we can handle
+ * everything in the legacy config.
+ */
+static const char *allowed_functions[] = {
+	[cdrel_record_cdr] = "CSV_QUOTE CDR CALLERID CHANNEL",
+	[cdrel_record_cel] = "CSV_QUOTE CALLERID CHANNEL eventtype eventtime eventenum userdeftype eventextra BRIDGEPEER",
+};
+
+static const char *special_vars[] = {
+	[cdrel_record_cdr] = "",
+	[cdrel_record_cel] = "eventtype eventtime eventenum userdeftype eventextra BRIDGEPEER",
+};
+
+/*!
+ * \internal
+ * \brief Parse a raw legacy field template.
+ *
+ * \example
+ *
+ * ${CSV_QUOTE(${eventtype})}
+ * ${CSV_QUOTE(${CALLERID(name)})}
+ * ${CSV_QUOTE(${CDR(src)})}
+ * ${CDR(uservar)}
+ * "some literal"
+ * ${CSV_QUOTE("some literal")}
+ *
+ * \param record_type CDR or CEL
+ * \param input_field_template The trimmed raw field template
+ * \return
+ */
+
+static struct field_parse_result parse_field(enum cdrel_record_type record_type, char *input_field_template)
+{
+	char *tmp_field = NULL;
+	struct field_parse_result result = { 0, };
+
+	/*
+	 * If the template starts with a double-quote, it's automatically
+	 * a literal.
+	 */
+	if (input_field_template[0] == '"') {
+		result.result = ast_strdup(ast_strip_quoted(ast_strdupa(input_field_template), "\"", "\""));
+		result.csv_quote = 1;
+		result.is_literal = 1;
+		return result;
+	}
+
+	/*
+	 * If it starts with a single quote, it's probably a legacy SQL template
+	 * so we need to force quote it on output.
+	 */
+	tmp_field = ast_strip(ast_strdupa(input_field_template));
+
+	if (tmp_field[0] == '\'') {
+		result.csv_quote = 1;
+	}
+
+	/*
+	 * I really hate the fact that ast_strip really trims whitespace
+	 * and ast_strip_quoted will strip anything in pairs.
+	 * Anyway, get rid of any remaining enclosing quotes.
+	 */
+	tmp_field = ast_strip(ast_strip_quoted(tmp_field, "\"'", "\"'"));
+
+	/*
+	 * If the template now starts with a '$' it's either a dialplan function
+	 * call or one of the special CEL field names.
+	 *
+	 * Examples: ${CSV_QUOTE(${CALLERID(name)})}
+	 *           ${eventtime}
+	 * We're going to iterate over function removal until there's just
+	 * a plain text string left.
+	 *
+	 */
+	while (tmp_field[0] == '$') {
+		char *ptr = NULL;
+		/*
+		 * A function name longer that 64 characters is highly unlikely but
+		 * we'll check later.
+		 */
+		char func_name[65];
+
+		/*
+		 * Skip over the '$'
+		 * {CSV_QUOTE(${CALLERID(name)})}
+		 * {eventtime}
+		 */
+		tmp_field++;
+		/*
+		 * Remove any enclosing brace-like characters
+		 * CSV_QUOTE(${CALLERID(name)})
+		 * eventtime
+		 */
+		tmp_field = ast_strip(ast_strip_quoted(tmp_field, "[{(", "]})"));
+
+		/*
+		 * Check what's left to see if it matches a special variable.
+		 * If it does (like "eventtime" in the example), we're done.
+		 */
+		if (strstr(special_vars[record_type], tmp_field) != NULL) {
+			result.functions++;
+			break;
+		}
+
+		/*
+		 * At this point, it has to be a function name so find the
+		 * openening '('.
+		 * CSV_QUOTE(${CALLERID(name)})
+		 *          ^
+		 * If we don't find one, it's something we don't recognise
+		 * so bail.
+		 */
+		ptr = strchr(tmp_field, '(');
+		if (!ptr) {
+			result.parse_failed++;
+			continue;
+		}
+
+		/*
+		 * Copy from the beginning to the '(' to func_name,
+		 * not exceeding func_name's size.
+		 *
+		 * CSV_QUOTE(${CALLERID(name)})
+		 *          ^
+		 * CSV_QUOTE
+		 *
+		 * Then check that it's a function we can handle.
+		 * If not, bail.
+		 */
+		ast_copy_string(func_name, tmp_field, MIN(sizeof(func_name), ptr - tmp_field + 1));
+		if (strstr(allowed_functions[record_type], func_name) == NULL) {
+			result.parse_failed++;
+			result.unknown_functions++;
+			continue;
+		}
+		result.functions++;
+		/*
+		 * If the function is CSV_QUOTE, we need to set the csv_quote flag.
+		 */
+		if (strcmp("CSV_QUOTE", func_name) == 0) {
+			result.csv_quote = 1;
+		} else if (strcmp("CDR", func_name) == 0) {
+			result.cdr = 1;
+		}
+
+		/*
+		 * ptr still points to the opening '(' so now strip it and the
+		 * matching parens.
+		 *
+		 * ${CALLERID(name)}
+		 *
+		 */
+		tmp_field = ast_strip_quoted(ptr, "(", ")");
+		if (tmp_field[0] == '"' || tmp_field[0] == '\'') {
+			result.result = ast_strdup(ast_strip_quoted(tmp_field, "\"'", "\"'"));
+			result.csv_quote = 1;
+			result.is_literal = 1;
+			return result;
+		}
+
+		/* Repeat the loop until there are no more functions or variables */
+	}
+
+	/*
+	 * If the parse failed we'll send back the entire template.
+	 */
+	if (result.parse_failed) {
+		tmp_field = input_field_template;
+	} else {
+		/*
+		 * If there were no functions or variables parsed then we'll
+		 * assume it's a literal.
+		 */
+		if (result.functions == 0) {
+			result.is_literal = 1;
+		}
+	}
+
+	result.result = ast_strdup(tmp_field);
+	if (result.result == NULL) {
+		result.parse_failed = 1;
+	}
+
+	return result;
+}
+
+/*!
+ * \internal
+ * \brief Parse a legacy DSV template string into a vector of individual strings.
+ *
+ * The resulting vector will look like it came from an advanced config and will
+ * be treated as such.
+ *
+ * \param record_type CDR or CEL.
+ * \param config_filename Config filename for logging purposes.
+ * \param output_filename Output filename for logging purposes.
+ * \param template The full template.
+ * \param fields A pointer to a string vector to receive the result.
+ * \retval 1 on success.
+ * \retval 0 on failure.
+ */
+static int parse_legacy_template(enum cdrel_record_type record_type, const char *config_filename,
+	const char *output_filename, const char *input_template, struct ast_vector_string *fields)
+{
+	char *template = ast_strdupa(input_template);
+	char *field_template = NULL;
+	int res = 0;
+
+	/*
+	 * We have no choice but to assume that a legacy config template uses commas
+	 * as field delimiters.  We don't have a reliable way to determine this ourselves.
+	 */
+	while((field_template = ast_strsep(&template, ',', AST_STRSEP_TRIM))) {
+		char *uservar = "";
+		char *literal = "";
+		/* Try to parse the field. */
+		struct field_parse_result result = parse_field(record_type, field_template);
+
+		ast_debug(2, "field: '%s' literal: %d quote: %d cdr: %d failed: %d funcs: %d unknfuncs: %d\n", result.result,
+			result.is_literal, result.csv_quote, result.cdr,
+			result.parse_failed, result.functions, result.unknown_functions);
+
+		/*
+		 * If it failed,
+		 */
+		if (!result.result || result.parse_failed) {
+			ast_free(result.result);
+			return 0;
+		}
+		if (result.is_literal) {
+			literal = "literal^";
+		}
+
+		if (!get_registered_field_by_name(record_type, result.result)) {
+			ast_debug(3, "   %s->%s: field '%s' not found\n", cdrel_basename(config_filename),
+				cdrel_basename(output_filename), result.result);
+			/*
+			 * If the result was found in a CDR function, treat it as a CDR user variable
+			 * otherwise treat it as a literal.
+			 */
+			if (result.cdr) {
+				uservar = "uservar^";
+			} else {
+				literal = "literal^";
+			}
+		}
+		res = ast_asprintf(&field_template, "%s(%s%s)", result.result, S_OR(literal,uservar), result.csv_quote ? "quote" : "noquote");
+		ast_free(result.result);
+
+		if (!field_template || res < 0) {
+			ast_free(field_template);
+			return 0;
+		}
+		res = AST_VECTOR_APPEND(fields, field_template);
+		if (res != 0) {
+			ast_free(field_template);
+			return 0;
+		}
+		ast_debug(2, "   field template: %s\n", field_template);
+	}
+
+	return 1;
+}
+
+/*!
+ * \fn Parse an advanced field template and allocate a cdrel_field for it.
+ * \brief
+ *
+ * \param config Config object.
+ * \param input_field_template Trimmed advanced field template.
+ * \return
+ */
+static struct cdrel_field *field_alloc(struct cdrel_config *config, const char *input_field_template)
+{
+	RAII_VAR(struct cdrel_field *, field, NULL, ast_free);
+	const struct cdrel_field *registered_field = NULL;
+	struct cdrel_field *rtn_field = NULL;
+	char *field_name = NULL;
+	char *data = NULL;
+	char *tmp_data = NULL;
+	char *closeparen = NULL;
+	char *qualifier = NULL;
+	enum cdrel_data_type forced_output_data_type = cdrel_data_type_end;
+	struct ast_flags field_flags = { 0 };
+
+	/*
+	 * The database fields are specified field-by-field for legacy so we treat them
+	 * as literals containing expressions which will be evaluated record-by-record.
+	 */
+	if (config->backend_type == cdrel_backend_db && config->config_type == cdrel_config_legacy) {
+		registered_field = get_registered_field_by_name(config->record_type, "literal");
+		ast_assert(registered_field != NULL);
+		rtn_field = ast_calloc(1, sizeof(*field) + strlen(input_field_template) + 1);
+		if (!rtn_field) {
+			return NULL;
+		}
+		memcpy(rtn_field, registered_field, sizeof(*registered_field));
+		strcpy(rtn_field->data, input_field_template); /* Safe */
+		return rtn_field;
+	}
+
+	/*
+	 * If the field template is a quoted string, it's a literal.
+	 * We don't check for qualifiers.
+	 */
+	if (input_field_template[0] == '"' || input_field_template[0] == '\'') {
+		data = ast_strip_quoted(ast_strdupa(input_field_template), "\"'", "\"'");
+		ast_set_flag(&field_flags, cdrel_flag_literal);
+		ast_debug(3, "   Using qualifier 'literal' for field '%s' flags: %s\n", data,
+			ast_str_tmp(128, cdrel_get_field_flags(&field_flags, &STR_TMP)));
+		field_name = "literal";
+	} else {
+		field_name = ast_strdupa(input_field_template);
+		data = strchr(field_name, '(');
+
+		if (data) {
+			*data = '\0';
+			data++;
+			closeparen = strchr(data, ')');
+			if (closeparen) {
+				*closeparen = '\0';
+			}
+		}
+	}
+
+	if (!ast_strlen_zero(data) && !ast_test_flag(&field_flags, cdrel_flag_literal)) {
+		char *data_swap = NULL;
+		tmp_data = ast_strdupa(data);
+
+		while((qualifier = ast_strsep(&tmp_data, '^', AST_STRSEP_STRIP | AST_STRSEP_TRIM))) {
+			enum cdrel_data_type fodt = cdrel_data_type_end;
+			if (ast_strlen_zero(qualifier)) {
+				continue;
+			}
+			fodt = cdrel_data_type_from_str(qualifier);
+			if (fodt < cdrel_data_type_end) {
+				ast_set_flag(&field_flags, cdrel_flag_type_forced);
+				if (fodt == cdrel_type_uservar) {
+					ast_set_flag(&field_flags, cdrel_flag_uservar);
+					ast_debug(3, "   Using qualifier '%s' for field '%s' flags: %s\n", qualifier,
+						field_name, ast_str_tmp(128, cdrel_get_field_flags(&field_flags, &STR_TMP)));
+					data_swap = ast_strdupa(field_name);
+					field_name = "uservar";
+				} else if (fodt == cdrel_type_literal) {
+					ast_set_flag(&field_flags, cdrel_flag_literal);
+					ast_debug(3, "   Using qualifier '%s' for field '%s' flags: %s\n", qualifier,
+						field_name, ast_str_tmp(128, cdrel_get_field_flags(&field_flags, &STR_TMP)));
+					data_swap = ast_strdupa(field_name);
+					field_name = "literal";
+				} else {
+					forced_output_data_type = fodt;
+					ast_debug(3, "   Using qualifier '%s' for field '%s' flags: %s\n", qualifier,
+						field_name, ast_str_tmp(128, cdrel_get_field_flags(&field_flags, &STR_TMP)));
+				}
+				continue;
+			}
+			if (strcasecmp(qualifier, "quote") == 0) {
+				ast_set_flag(&field_flags, cdrel_flag_quote);
+				ast_debug(3, "   Using qualifier '%s' for field '%s' flags: %s\n", qualifier,
+					field_name, ast_str_tmp(128, cdrel_get_field_flags(&field_flags, &STR_TMP)));
+				continue;
+			}
+			if (strcasecmp(qualifier, "noquote") == 0) {
+				ast_set_flag(&field_flags, cdrel_flag_noquote);
+				ast_debug(3, "   Using qualifier '%s' for field '%s' flags: %s\n", qualifier,
+					field_name, ast_str_tmp(128, cdrel_get_field_flags(&field_flags, &STR_TMP)));
+				continue;
+			}
+			if (strchr(qualifier, '%') != NULL) {
+				data_swap = ast_strdupa(qualifier);
+				ast_set_flag(&field_flags, cdrel_flag_format_spec);
+				ast_debug(3, "   Using qualifier '%s' for field '%s' flags: %s\n", qualifier,
+					field_name, ast_str_tmp(128, cdrel_get_field_flags(&field_flags, &STR_TMP)));
+			}
+		}
+		if (ast_test_flag(&field_flags, cdrel_flag_quote) && ast_test_flag(&field_flags, cdrel_flag_noquote)) {
+			ast_log(LOG_WARNING, "%s->%s: Field '%s(%s)' has both quote and noquote\n",
+				config->config_filename, config->output_filename, field_name, data);
+			return NULL;
+		}
+		data = data_swap;
+	}
+
+	/*
+	 * Check again for literal.
+	 */
+	if (ast_test_flag(&field_flags, cdrel_flag_literal)) {
+		if (config->format_type == cdrel_format_json && !strchr(data, ':')) {
+			ast_log(LOG_WARNING, "%s->%s: Literal field '%s' must be formatted as \"name: value\" when using the 'json' format\n",
+				cdrel_basename(config->config_filename), cdrel_basename(config->output_filename),
+				input_field_template);
+			return NULL;
+		}
+	}
+
+	/*
+	 * Now look the field up by just the field name without any data.
+	 */
+	registered_field = get_registered_field_by_name(config->record_type, field_name);
+	if (!registered_field) {
+		ast_log(LOG_WARNING, "%s->%s: Field '%s' not found\n",
+			cdrel_basename(config->config_filename), cdrel_basename(config->output_filename), field_name);
+		return NULL;
+	}
+
+	field = ast_calloc(1, sizeof(*registered_field) + strlen(input_field_template) + 1);
+	if (!field) {
+		return NULL;
+	}
+	memcpy(field, registered_field, sizeof(*field));
+
+	if (!ast_strlen_zero(data)) {
+		strcpy(field->data, data); /* Safe */
+	}
+
+	/*
+	 * For user variables, we use the field name from the data
+	 * we set above.
+	 */
+	if (field->input_data_type == cdrel_type_uservar) {
+		field->name = field->data;
+	}
+
+	if (field->input_data_type == cdrel_type_literal && config->format_type == cdrel_format_json) {
+		/*
+		 * data should look something like this...  lname: lvalue
+		 * We'll need to make field->name point to "lname" and
+		 * field->data point to "lvalue" so that when output the
+		 * json will look like...  "lname": "lvalue".
+		 * Since field->data is already long enough to to handle both,
+		 * we'll do this...
+		 * field->data = lvalue\0lname\0
+		 * field->name =         ^
+		 */
+		char *ptr = strchr(data, ':');/* Safe since we checked data for a ':' above */
+		*ptr = '\0';
+		ptr++;
+		/*
+		 * data: lname\0 lvalue
+		 * ptr:         ^
+		 */
+		strcpy(field->data, ast_strip_quoted(ptr, "\"", "\"")); /* Safe */
+		/*
+		 * field->data: lvalue\0
+		 */
+		ptr = field->data + strlen(field->data);
+		ptr++;
+		/*
+		 * field->data: lvalue\0
+		 * ptr:                 ^
+		 * data: lname\0 lvalue
+		 */
+		strcpy(ptr, data); /* Safe */
+		/*
+		 * field->data: lvalue\0lname\0
+		 */
+		field->name = ptr;
+		/*
+		 * field->data: lvalue\0lname\0
+		 * field->name:         ^
+		 */
+	}
+
+	if (forced_output_data_type < cdrel_data_type_end) {
+		field->output_data_type = forced_output_data_type;
+	}
+	field->flags = field_flags;
+
+	/*
+	 * Unless the field has the 'noquote' flag, we'll set the 'quote'
+	 * flag if quoting method is 'all' or 'non_numeric'.
+	 */
+	if (!ast_test_flag(&field->flags, cdrel_flag_noquote)) {
+		if (config->quoting_method == cdrel_quoting_method_all) {
+			ast_set_flag(&field->flags, cdrel_flag_quote);
+		} else if (config->quoting_method == cdrel_quoting_method_non_numeric) {
+			if (field->output_data_type > cdrel_data_type_strings_end) {
+				ast_set_flag(&field->flags, cdrel_flag_noquote);
+			} else {
+				ast_set_flag(&field->flags, cdrel_flag_quote);
+			}
+		}
+	}
+
+	if (config->quoting_method == cdrel_quoting_method_none) {
+		ast_clear_flag(&field->flags, cdrel_flag_quote);
+		ast_set_flag(&field->flags, cdrel_flag_noquote);
+	}
+
+	ast_debug(2, "%s->%s: Field '%s' processed -> name:'%s' input_type:%s output_type:%s flags:'%s' data:'%s'\n",
+		cdrel_basename(config->config_filename), cdrel_basename(config->output_filename), input_field_template,
+		field->name, DATA_TYPE_STR(field->input_data_type),
+		DATA_TYPE_STR(field->output_data_type),
+		ast_str_tmp(128, cdrel_get_field_flags(&field->flags, &STR_TMP)),
+		field->data);
+
+	rtn_field = field;
+	field = NULL;
+	return rtn_field;
+}
+
+static void field_template_vector_free(struct ast_vector_string *fields) {
+	AST_VECTOR_RESET(fields, ast_free);
+	AST_VECTOR_PTR_FREE(fields);
+}
+
+/*!
+ * \internal
+ * \brief Load all the fields in the string vector.
+ *
+ * \param config Config object
+ * \param fields String vector.
+ * \retval 0 on success.
+ * \retval -1 on failure.
+ */
+static int load_fields(struct cdrel_config *config, struct ast_vector_string *fields)
+{
+	int res = 0;
+	int ix = 0;
+
+	ast_debug(1, "%s->%s: Loading fields\n", cdrel_basename(config->config_filename),
+		cdrel_basename(config->output_filename));
+
+	for (ix = 0; ix < AST_VECTOR_SIZE(fields); ix++) {
+		char *field_name = AST_VECTOR_GET(fields, ix);
+		struct cdrel_field *field = NULL;
+
+		field = field_alloc(config, field_name);
+		if (!field) {
+			res = -1;
+			continue;
+		}
+
+		if (AST_VECTOR_APPEND(&config->fields, field) != 0) {
+			ast_free(field);
+			return -1;
+		}
+	}
+
+	return res;
+}
+
+static void config_free(struct cdrel_config *config)
+{
+	if (!config) {
+		return;
+	}
+
+	if (config->insert) {
+		sqlite3_finalize(config->insert);
+		config->insert = NULL;
+	}
+
+	if (config->db) {
+		sqlite3_close(config->db);
+		config->db = NULL;
+	}
+
+	ast_mutex_destroy(&config->lock);
+	ast_string_field_free_memory(config);
+	AST_VECTOR_RESET(&config->fields, ast_free);
+	AST_VECTOR_FREE(&config->fields);
+	ast_free(config);
+}
+
+/*!
+ * \internal
+ * \brief Allocate a config object.
+ *
+ * You should know what these are by now :)
+ *
+ * \param record_type
+ * \param backend_type
+ * \param config_type
+ * \param config_filename
+ * \param output_filename
+ * \param template
+ * \return
+ */
+static struct cdrel_config *config_alloc(enum cdrel_record_type record_type,
+	enum cdrel_backend_type backend_type, enum cdrel_config_type config_type,
+	const char *config_filename, const char *output_filename, const char *template)
+{
+	RAII_VAR(struct cdrel_config *, config, NULL, config_free);
+	struct cdrel_config *rtn_config = NULL;
+	const char *file_suffix = "";
+	int res = 0;
+
+	ast_debug(1, "%s->%s: Loading\n", cdrel_basename(config_filename), cdrel_basename(output_filename));
+
+	config = ast_calloc_with_stringfields(1, struct cdrel_config, 1024);
+	if (!config) {
+		return NULL;
+	}
+
+	if (ast_string_field_set(config, config_filename, config_filename) != 0) {
+		return NULL;
+	}
+
+	config->record_type = record_type;
+	config->backend_type = backend_type;
+	config->dummy_channel_alloc = cdrel_dummy_channel_allocators[record_type];
+	config->config_type = config_type;
+
+	/* Set defaults */
+	config->format_type = cdrel_format_dsv;
+	config->separator[0] = ',';
+	switch(backend_type) {
+	case cdrel_backend_text:
+		config->quote[0] = '"';
+		config->quoting_method = cdrel_quoting_method_all;
+		break;
+	case cdrel_backend_db:
+		config->quote[0] = '\0';
+		config->format_type = cdrel_format_sql;
+		config->quoting_method = cdrel_quoting_method_none;
+		if (!ast_ends_with(output_filename, ".db")) {
+			file_suffix = ".db";
+		}
+		break;
+	default:
+		ast_log(LOG_ERROR, "%s->%s: Unknown backend type '%d'\n", cdrel_basename(config_filename),
+			cdrel_basename(output_filename), backend_type);
+		break;
+	}
+	config->quote_escape[0] = config->quote[0];
+
+	res = ast_string_field_set(config, template, template);
+	if (res != 0) {
+		return NULL;
+	}
+
+	if (output_filename[0] == '/') {
+		res = ast_string_field_build(config, output_filename, "%s%s", output_filename, file_suffix);
+	} else {
+		const char *subdir = dirname_map[backend_type][record_type];
+		res = ast_string_field_build(config, output_filename, "%s/%s%s%s%s",
+			ast_config_AST_LOG_DIR, S_OR(subdir, ""), ast_strlen_zero(subdir) ? "" : "/", output_filename, file_suffix);
+	}
+	if (res != 0) {
+		return NULL;
+	}
+	ast_mutex_init(&config->lock);
+
+	rtn_config = config;
+	config = NULL;
+	return rtn_config;
+}
+
+/*!
+ * \internal
+ * \brief Load the "columns" parameter from a database config.
+ *
+ * \param config Config object
+ * \param columns The columns parameter.
+ * \param column_count Set to the count of columns parsed.
+ * \retval 0 on success.
+ * \retval -1 on failure.
+ */
+static int load_database_columns(struct cdrel_config *config, const char *columns, int *column_count)
+{
+	char *col = NULL;
+	char *cols = NULL;
+	RAII_VAR(struct ast_str *, column_string, NULL, ast_free);
+
+	ast_debug(1, "%s->%s: Loading columns\n", cdrel_basename(config->config_filename),
+		cdrel_basename(config->output_filename));
+
+	if (!(column_string = ast_str_create(1024))) {
+		return -1;
+	}
+
+	cols = ast_strdupa(columns);
+	*column_count = 0;
+
+	/* We need to trim and remove any single or double quotes from each column name. */
+	while((col = ast_strsep(&cols, ',', AST_STRSEP_TRIM))) {
+		col = ast_strip(ast_strip_quoted(col, "'\"", "'\""));
+		if (ast_str_append(&column_string, 0, "%s%s", *column_count ? "," : "", col) <= 0) {
+			return -1;
+		}
+		(*column_count)++;
+	}
+
+	if (ast_string_field_set(config, db_columns, ast_str_buffer(column_string)) != 0) {
+		return -1;
+	}
+
+	return 0;
+}
+
+static char *make_stmt_placeholders(int columns)
+{
+	char *placeholders = ast_malloc(2 * columns), *c = placeholders;
+	if (placeholders) {
+		for (; columns; columns--) {
+			*c++ = '?';
+			*c++ = ',';
+		}
+		*(c - 1) = 0;
+	}
+	return placeholders;
+}
+
+/*!
+ * \internal
+ * \brief Open an sqlite3 database and create the table if needed.
+ *
+ * \param config Config object.
+ * \retval 0 on success.
+ * \retval -1 on failure.
+ */
+static int open_database(struct cdrel_config *config)
+{
+	char *sql = NULL;
+	int res = 0;
+	char *placeholders = NULL;
+
+	ast_debug(1, "%s->%s: opening database\n", cdrel_basename(config->config_filename),
+		cdrel_basename(config->output_filename));
+	res = sqlite3_open(config->output_filename, &config->db);
+	if (res != SQLITE_OK) {
+		ast_log(LOG_WARNING, "%s->%s: Could not open database\n", cdrel_basename(config->config_filename),
+			cdrel_basename(config->output_filename));
+		return -1;
+	}
+
+	sqlite3_busy_timeout(config->db, config->busy_timeout);
+
+	/* is the table there? */
+	sql = sqlite3_mprintf("SELECT COUNT(*) FROM %q;", config->db_table);
+	if (!sql) {
+		return -1;
+	}
+	res = sqlite3_exec(config->db, sql, NULL, NULL, NULL);
+	sqlite3_free(sql);
+	if (res != SQLITE_OK) {
+		/*
+		 * Create the table.
+		 * We don't use %q for the column list here since we already escaped when building it
+		 */
+		sql = sqlite3_mprintf("CREATE TABLE %q (AcctId INTEGER PRIMARY KEY, %s)",
+			config->db_table, config->db_columns);
+		res = sqlite3_exec(config->db, sql, NULL, NULL, NULL);
+		sqlite3_free(sql);
+		if (res != SQLITE_OK) {
+			ast_log(LOG_WARNING, "%s->%s: Unable to create table '%s': %s\n",
+				cdrel_basename(config->config_filename), cdrel_basename(config->output_filename),
+				config->db_table, sqlite3_errmsg(config->db));
+			return -1;
+		}
+	} else {
+		/*
+		 * If the table exists, make sure the number of columns
+		 * matches the config.
+		 */
+		sqlite3_stmt *get_stmt;
+		int existing_columns = 0;
+		int config_columns = AST_VECTOR_SIZE(&config->fields);
+
+		sql = sqlite3_mprintf("SELECT * FROM %q;", config->db_table);
+		if (!sql) {
+			return -1;
+		}
+		res = sqlite3_prepare_v2(config->db, sql, -1, &get_stmt, NULL);
+		sqlite3_free(sql);
+		if (res != SQLITE_OK) {
+			ast_log(LOG_WARNING, "%s->%s: Unable to get column count for table '%s': %s\n",
+				cdrel_basename(config->config_filename), cdrel_basename(config->output_filename),
+				config->db_table, sqlite3_errmsg(config->db));
+			return -1;
+		}
+		/*
+		 * prepare figures out the number of columns that would be in a result
+		 * set. We don't need to execute the statement.
+		 */
+		existing_columns = sqlite3_column_count(get_stmt);
+		sqlite3_finalize(get_stmt);
+		/* config_columns doesn't include the sequence field */
+		if ((config_columns + 1) != existing_columns) {
+			ast_log(LOG_WARNING, "%s->%s: The number of fields in the config (%d) doesn't equal the"
+				" nummber of data columns (%d) in the existing %s table. This config is disabled.\n",
+				cdrel_basename(config->config_filename), cdrel_basename(config->output_filename),
+				config_columns, existing_columns - 1, config->db_table);
+			return -1;
+		}
+	}
+
+	placeholders = make_stmt_placeholders(AST_VECTOR_SIZE(&config->fields));
+	if (!placeholders) {
+		return -1;
+	}
+
+	/* Inserting NULL in the ID column still generates an ID */
+	sql = sqlite3_mprintf("INSERT INTO %q VALUES (NULL,%s)", config->db_table, placeholders);
+	ast_free(placeholders);
+	if (!sql) {
+		return -1;
+	}
+
+	res = sqlite3_prepare_v3(config->db, sql, -1, SQLITE_PREPARE_PERSISTENT, &config->insert, NULL);
+	if (res != SQLITE_OK) {
+		ast_log(LOG_ERROR, "%s->%s: Unable to prepare INSERT statement '%s': %s\n",
+			cdrel_basename(config->config_filename), cdrel_basename(config->output_filename),
+			sql, sqlite3_errmsg(config->db));
+		return -1;
+	}
+
+	return 0;
+}
+
+/*!
+ * \internal
+ * \brief Load a database config from a config file category.
+ *
+ * \param record_type CDR or CEL.
+ * \param category The category (becomes the database file name).
+ * \param config_filename The config filename for logging purposes.
+ * \return config or NULL.
+ */
+static struct cdrel_config *load_database_config(enum cdrel_record_type record_type,
+	struct ast_category *category, const char *config_filename)
+{
+	const char *category_name = ast_category_get_name(category);
+	RAII_VAR(struct cdrel_config *, config, NULL, config_free);
+	struct cdrel_config *rtn_config = NULL;
+	int res = 0;
+	int column_count = 0;
+	int value_count = 0;
+	int field_check_passed = 0;
+	const char *template = ast_variable_find(category, "values");
+	enum cdrel_config_type config_type;
+	const char *value;
+	char *tmp_fields = NULL;
+	RAII_VAR(struct ast_vector_string *, field_templates, ast_calloc(1, sizeof(*field_templates)), field_template_vector_free);
+
+	if (!ast_strlen_zero(template)) {
+		config_type = cdrel_config_legacy;
+	} else {
+		template = ast_variable_find(category, "fields");
+		if (!ast_strlen_zero(template)) {
+			config_type = cdrel_config_advanced;
+		}
+	}
+	if (ast_strlen_zero(template)) {
+		ast_log(LOG_WARNING, "%s->%s: Neither 'values' nor 'fields' specified\n",
+			cdrel_basename(config_filename), cdrel_basename(category_name));
+		return NULL;
+	}
+
+	res = AST_VECTOR_INIT(field_templates, 25);
+	if (res != 0) {
+		return NULL;
+	}
+
+	/*
+	 * Let's try and and parse a legacy config to see if we can turn
+	 * it into an advanced condig.
+	 */
+	if (config_type == cdrel_config_legacy) {
+		field_check_passed = parse_legacy_template(record_type, config_filename,
+			category_name, template, field_templates);
+		if (field_check_passed) {
+			config_type = cdrel_config_advanced;
+			ast_log(LOG_NOTICE, "%s->%s: Legacy config upgraded to advanced\n",
+				cdrel_basename(config_filename), cdrel_basename(category_name));
+		} else {
+			AST_VECTOR_RESET(field_templates, ast_free);
+			ast_log(LOG_NOTICE, "%s->%s: Unable to upgrade legacy config to advanced. Continuing to process as legacy\n",
+				cdrel_basename(config_filename), cdrel_basename(category_name));
+		}
+	}
+
+	/*
+	 * If we could, the fields vector will be populated so we don't need to do it again.
+	 * If it was an advanced config or a legacy one we couldn't parse,
+	 * we need to split the string into the vector.
+	 */
+	if (AST_VECTOR_SIZE(field_templates) == 0) {
+		tmp_fields = ast_strdupa(template);
+		while((value = ast_strsep(&tmp_fields, ',', AST_STRSEP_TRIM))) {
+			res = AST_VECTOR_APPEND(field_templates, ast_strdup(value));
+			if (res != 0) {
+				return NULL;
+			}
+		}
+	}
+
+	config = config_alloc(record_type, cdrel_backend_db, config_type,
+		config_filename, category_name, template);
+	if (!config) {
+		return NULL;
+	}
+
+	config->busy_timeout = 1000;
+
+	res = ast_string_field_set(config, db_table,
+		S_OR(ast_variable_find(category, "table"), config->record_type == cdrel_record_cdr ? "cdr" : "cel"));
+	if (res != 0) {
+		return NULL;
+	}
+
+	/* sqlite3_busy_timeout in miliseconds */
+	if ((value = ast_variable_find(category, "busy_timeout")) != NULL) {
+		if (ast_parse_arg(value, PARSE_INT32|PARSE_DEFAULT, &config->busy_timeout, 1000) != 0) {
+			ast_log(LOG_WARNING, "%s->%s: Invalid busy_timeout value '%s' specified. Using 1000 instead.\n",
+				cdrel_basename(config->config_filename), cdrel_basename(config->output_filename), value);
+		}
+	}
+
+	/* Columns */
+	value = ast_variable_find(category, "columns");
+	if (ast_strlen_zero(value)) {
+		ast_log(LOG_WARNING, "%s->%s: The 'columns' parameter is missing",
+			cdrel_basename(config->config_filename), cdrel_basename(config->output_filename));
+		return NULL;
+	}
+
+	if (load_database_columns(config, value, &column_count) != 0) {
+		return NULL;
+	}
+
+	if (AST_VECTOR_INIT(&config->fields, AST_VECTOR_SIZE(field_templates)) != 0) {
+		return NULL;
+	}
+
+	if (load_fields(config, field_templates) != 0) {
+		return NULL;
+	}
+
+	value_count = AST_VECTOR_SIZE(&config->fields);
+
+	if (value_count != column_count) {
+		ast_log(LOG_WARNING, "%s->%s: There are %d columns but %d values\n",
+			cdrel_basename(config->config_filename), cdrel_basename(config->output_filename),
+			column_count, value_count);
+		return NULL;
+	}
+
+	res = open_database(config);
+	if (res != 0) {
+		return NULL;
+	}
+
+	ast_log(LOG_NOTICE, "%s->%s: Logging %s records to table '%s'\n",
+		cdrel_basename(config->config_filename), cdrel_basename(config->output_filename),
+		RECORD_TYPE_STR(config->record_type),
+		config->db_table);
+
+	rtn_config = config;
+	config = NULL;
+	return rtn_config;
+}
+
+/*!
+ * \internal
+ * \brief Load all the categories in a database config file.
+ *
+ * \param record_type
+ * \param configs
+ * \param config_filename
+ * \param reload
+ * \retval 0 on success or reload not needed.
+ * \retval -1 on failure.
+ */
+static int load_database_config_file(enum cdrel_record_type record_type, struct cdrel_configs *configs,
+	const char *config_filename, int reload)
+{
+	struct ast_config *cfg;
+	struct ast_flags config_flags = { reload ? CONFIG_FLAG_FILEUNCHANGED : 0 };
+	struct ast_category *category = NULL;
+
+	ast_debug(1, "%s: %soading\n", config_filename, reload ? "Rel" : "L");
+	cfg = ast_config_load(config_filename, config_flags);
+	if (!cfg || cfg == CONFIG_STATUS_FILEINVALID) {
+		ast_log(LOG_ERROR, "Unable to load %s. Not logging %ss to custom database\n",
+			config_filename, RECORD_TYPE_STR(record_type));
+		return -1;
+	} else if (cfg == CONFIG_STATUS_FILEUNCHANGED) {
+		ast_debug(1, "%s: Config file unchanged, not reloading\n", config_filename);
+		return 0;
+	}
+
+	while ((category = ast_category_browse_filtered(cfg, NULL, category, NULL))) {
+		struct cdrel_config *config = NULL;
+
+		config = load_database_config(record_type, category, config_filename);
+		if (!config) {
+			continue;
+		}
+
+		if (AST_VECTOR_APPEND(configs, config) != 0) {
+			config_free(config);
+			break;
+		}
+	}
+
+	ast_config_destroy(cfg);
+
+	ast_log(LOG_NOTICE, "%s: Loaded %d configs\n", config_filename, (int)AST_VECTOR_SIZE(configs));
+
+	/* Only fail if no configs were valid. */
+	return AST_VECTOR_SIZE(configs) > 0 ? 0 : -1;
+}
+
+/*!
+ * \internal
+ * \brief Load a legacy config from a single entry in the "mappings" castegory.
+ *
+ * \param record_type
+ * \param config_filename
+ * \param output_filename
+ * \param template
+ * \return config or NULL.
+ */
+static struct cdrel_config *load_text_file_legacy_config(enum cdrel_record_type record_type,
+	const char *config_filename, const char *output_filename, const char *template)
+{
+	struct cdrel_config *config = NULL;
+	int field_check_passed = 0;
+	int res = 0;
+	RAII_VAR(struct ast_vector_string *, fields, ast_calloc(1, sizeof(*fields)), field_template_vector_free);
+
+	res = AST_VECTOR_INIT(fields, 25);
+	if (res != 0) {
+		return NULL;
+	}
+
+	/*
+	 * Let's try and and parse a legacy config to see if we can turn
+	 * it into an advanced condig.
+	 */
+	field_check_passed = parse_legacy_template(record_type, config_filename,
+		output_filename, template, fields);
+
+	/*
+	 * If we couldn't, treat as legacy.
+	 */
+	if (!field_check_passed) {
+		config = config_alloc(record_type, cdrel_backend_text, cdrel_config_legacy,
+			config_filename, output_filename, template);
+		ast_log(LOG_NOTICE, "%s->%s: Logging legacy %s records\n",
+			cdrel_basename(config->config_filename), cdrel_basename(config->output_filename),
+			RECORD_TYPE_STR(config->record_type));
+		return config;
+	}
+
+	config = config_alloc(record_type, cdrel_backend_text, cdrel_config_advanced,
+		config_filename, output_filename, template);
+	if (!config) {
+		return NULL;
+	}
+	config->format_type = cdrel_format_dsv;
+	config->quote[0] = '"';
+	config->quote_escape[0] = '"';
+	config->separator[0] = ',';
+	config->quoting_method = cdrel_quoting_method_all;
+
+	if (AST_VECTOR_INIT(&config->fields, AST_VECTOR_SIZE(fields)) != 0) {
+		return NULL;
+	}
+
+	if (load_fields(config, fields) != 0) {
+		return NULL;
+	}
+
+	ast_log(LOG_NOTICE, "%s->%s: Logging %s records\n",
+		cdrel_basename(config->config_filename), cdrel_basename(config->output_filename),
+		RECORD_TYPE_STR(config->record_type));
+
+	return config;
+}
+
+/*!
+ * \internal
+ * \brief Load an advanced config from a config file category.
+ *
+ * \param record_type
+ * \param category
+ * \param config_filename
+ * \return config or NULL.
+ */
+static struct cdrel_config *load_text_file_advanced_config(enum cdrel_record_type record_type,
+	struct ast_category *category, const char *config_filename)
+{
+	const char *category_name = ast_category_get_name(category);
+	RAII_VAR(struct cdrel_config *, config, NULL, config_free);
+	struct cdrel_config *rtn_config = NULL;
+	const char *value;
+	int res = 0;
+	const char *fields_value = ast_variable_find(category, "fields");
+	char *tmp_fields = NULL;
+	RAII_VAR(struct ast_vector_string *, fields, ast_calloc(1, sizeof(*fields)), field_template_vector_free);
+
+	if (ast_strlen_zero(fields_value)) {
+		ast_log(LOG_WARNING, "%s->%s: Missing 'fields' parameter\n",
+			cdrel_basename(config_filename), category_name);
+		return NULL;
+	}
+
+	config = config_alloc(record_type, cdrel_backend_text, cdrel_config_advanced,
+		config_filename, category_name, fields_value);
+
+	value = ast_variable_find(category, "format");
+	if (!ast_strlen_zero(value)) {
+		if (ast_strings_equal(value, "json")) {
+			config->format_type = cdrel_format_json;
+			config->separator[0] = ',';
+			config->quote[0] = '"';
+			config->quote_escape[0] = '\\';
+			config->quoting_method = cdrel_quoting_method_non_numeric;
+		} else if (ast_strings_equal(value, "dsv")) {
+			config->format_type = cdrel_format_dsv;
+		} else {
+			ast_log(LOG_WARNING, "%s->%s: Invalid format '%s'\n",
+				cdrel_basename(config->config_filename), cdrel_basename(config->output_filename), value);
+			return NULL;
+		}
+	}
+
+	if (config->format_type != cdrel_format_json) {
+		value = ast_variable_find(category, "separator_character");
+		if (!ast_strlen_zero(value)) {
+			ast_copy_string(config->separator, ast_unescape_c(ast_strdupa(value)), 2);
+		}
+
+		value = ast_variable_find(category, "quote_character");
+		if (!ast_strlen_zero(value)) {
+			ast_copy_string(config->quote, value, 2);
+		}
+
+		value = ast_variable_find(category, "quote_escape_character");
+		if (!ast_strlen_zero(value)) {
+			ast_copy_string(config->quote_escape, value, 2);
+		}
+
+		value = ast_variable_find(category, "quoting_method");
+		if (!ast_strlen_zero(value)) {
+			if (ast_strings_equal(value, "all")) {
+				config->quoting_method = cdrel_quoting_method_all;
+			} else if (ast_strings_equal(value, "minimal")) {
+				config->quoting_method = cdrel_quoting_method_minimal;
+			} else if (ast_strings_equal(value, "non_numeric")) {
+				config->quoting_method = cdrel_quoting_method_non_numeric;
+			} else if (ast_strings_equal(value, "none")) {
+				config->quoting_method = cdrel_quoting_method_none;
+			} else {
+				ast_log(LOG_WARNING, "%s->%s: Invalid quoting method '%s'\n",
+					cdrel_basename(config->config_filename), cdrel_basename(config->output_filename), value);
+				return NULL;
+			}
+		}
+	}
+
+	res = AST_VECTOR_INIT(fields, 20);
+	if (res != 0) {
+		return NULL;
+	}
+	tmp_fields = ast_strdupa(fields_value);
+	while((value = ast_strsep(&tmp_fields, ',', AST_STRSEP_TRIM))) {
+		res = AST_VECTOR_APPEND(fields, ast_strdup(value));
+		if (res != 0) {
+			return NULL;
+		}
+	}
+
+	if (AST_VECTOR_INIT(&config->fields, AST_VECTOR_SIZE(fields)) != 0) {
+		return NULL;
+	}
+
+	if (load_fields(config, fields) != 0) {
+		return NULL;
+	}
+
+	ast_log(LOG_NOTICE, "%s->%s: Logging %s records\n",
+		cdrel_basename(config->config_filename), cdrel_basename(config->output_filename),
+		RECORD_TYPE_STR(config->record_type));
+
+	rtn_config = config;
+	config = NULL;
+
+	return rtn_config;
+}
+
+/*!
+ * \internal
+ * \brief Load a legacy configs from the "mappings" category.
+ *
+ * \param record_type
+ * \param configs
+ * \param category
+ * \param config_filename
+ * \retval 0 on success.
+ * \retval -1 on failure.
+ */
+static int load_text_file_legacy_mappings(enum cdrel_record_type record_type,
+	struct cdrel_configs *configs, struct ast_category *category,
+	const char *config_filename)
+{
+	struct ast_variable *var = NULL;
+
+	for (var = ast_category_first(category); var; var = var->next) {
+		struct cdrel_config *config = NULL;
+
+		if (ast_strlen_zero(var->name) || ast_strlen_zero(var->value)) {
+			ast_log(LOG_WARNING, "%s: %s mapping must have both a filename and a template at line %d\n",
+				cdrel_basename(config_filename), RECORD_TYPE_STR(config->record_type), var->lineno);
+			continue;
+		}
+
+		config = load_text_file_legacy_config(record_type, config_filename, var->name, var->value);
+		if (!config) {
+			continue;
+		}
+
+		if (AST_VECTOR_APPEND(configs, config) != 0) {
+			config_free(config);
+			return -1;
+		}
+	}
+
+	return 0;
+}
+
+/*!
+ * \internal
+ * \brief Load all text file backend configs from a config file.
+ *
+ * \param record_type
+ * \param configs
+ * \param config_filename
+ * \param reload
+ * \return
+ */
+static int load_text_file_config_file(enum cdrel_record_type record_type,
+	struct cdrel_configs *configs, const char *config_filename, int reload)
+{
+	struct ast_config *cfg;
+	struct ast_flags config_flags = { reload ? CONFIG_FLAG_FILEUNCHANGED : 0 };
+	struct ast_category *category = NULL;
+
+	ast_debug(1, "%s: %soading\n", config_filename, reload ? "Rel" : "L");
+	cfg = ast_config_load(config_filename, config_flags);
+	if (!cfg || cfg == CONFIG_STATUS_FILEINVALID) {
+		ast_log(LOG_ERROR, "Unable to load %s. Not logging %ss to custom files\n",
+			config_filename, RECORD_TYPE_STR(record_type));
+		return -1;
+	} else if (cfg == CONFIG_STATUS_FILEUNCHANGED) {
+		ast_debug(1, "%s: Config file unchanged, not reloading\n", config_filename);
+		return 0;
+	}
+
+	while ((category = ast_category_browse_filtered(cfg, NULL, category, NULL))) {
+		const char *category_name = ast_category_get_name(category);
+
+		if (ast_strings_equal(category_name, "mappings")) {
+			load_text_file_legacy_mappings(record_type, configs, category, config_filename);
+		} else {
+			struct cdrel_config * config = load_text_file_advanced_config(record_type, category,
+				config_filename);
+			if (!config) {
+				continue;
+			}
+			if (AST_VECTOR_APPEND(configs, config) != 0) {
+				config_free(config);
+				return -1;
+			}
+		}
+	}
+
+	ast_config_destroy(cfg);
+
+	ast_log(LOG_NOTICE, "%s: Loaded %d configs\n", config_filename, (int)AST_VECTOR_SIZE(configs));
+
+	/* Only fail if no configs were valid. */
+	return AST_VECTOR_SIZE(configs) > 0 ? 0 : -1;
+}
+
+static int register_backend(enum cdrel_record_type record_type, const char *backend_name, void *log_cb)
+{
+	switch(record_type) {
+	case cdrel_record_cdr:
+		return 	ast_cdr_register(backend_name, "", log_cb);
+	case cdrel_backend_db:
+		return ast_cel_backend_register(backend_name, log_cb);
+	default:
+		return -1;
+	}
+}
+
+static int unregister_backend(enum cdrel_record_type record_type, const char *backend_name)
+{
+	switch(record_type) {
+	case cdrel_record_cdr:
+		return ast_cdr_unregister(backend_name);
+	case cdrel_record_cel:
+		return ast_cel_backend_unregister(backend_name);
+	default:
+		return -1;
+	}
+}
+
+static int load_config_file(enum cdrel_backend_type output_type, enum cdrel_record_type record_type,
+	struct cdrel_configs *configs, const char *filename, int reload)
+{
+	switch(output_type) {
+	case cdrel_backend_text:
+		return load_text_file_config_file(record_type, configs, filename, reload);
+	case cdrel_backend_db:
+		return load_database_config_file(record_type, configs, filename, reload);
+	default:
+		return -1;
+	}
+}
+
+int cdrel_reload_module(enum cdrel_backend_type output_type, enum cdrel_record_type record_type,
+	struct cdrel_configs **configs, const char *filename)
+{
+	int res = 0;
+	struct cdrel_configs *old_configs = *configs;
+	struct cdrel_configs *new_configs = NULL;
+
+	/*
+	 * Save new config to a temporary vector to make sure the
+	 * configs are valid before swapping them in.
+	 */
+	new_configs = ast_malloc(sizeof(*new_configs));
+	if (!new_configs) {
+		return AST_MODULE_LOAD_DECLINE;
+	}
+
+	if (AST_VECTOR_INIT(new_configs, AST_VECTOR_SIZE(old_configs)) != 0) {
+		return AST_MODULE_LOAD_DECLINE;
+	}
+
+	res = load_config_file(output_type, record_type, new_configs, filename, 1);
+	if (res != 0) {
+		AST_VECTOR_RESET(new_configs, config_free);
+		AST_VECTOR_PTR_FREE(new_configs);
+		return AST_MODULE_LOAD_DECLINE;
+	}
+
+	/* Now swap the new ones in. */
+	*configs = new_configs;
+
+	/* Free the old ones. */
+	AST_VECTOR_RESET(old_configs, config_free);
+	AST_VECTOR_PTR_FREE(old_configs);
+
+	return AST_MODULE_LOAD_SUCCESS;
+
+
+	return -1;
+}
+
+struct cdrel_configs *cdrel_load_module(enum cdrel_backend_type backend_type,
+	enum cdrel_record_type record_type, const char *filename, const char *backend_name,
+	void *log_cb)
+{
+	struct cdrel_configs *configs = ast_calloc(1, sizeof(*configs));
+	if (!configs) {
+		return NULL;
+	}
+	ast_debug(1, "Loading %s %s\n", RECORD_TYPE_STR(record_type), MODULE_TYPE_STR(backend_type));
+
+	if (AST_VECTOR_INIT(configs, 5) != 0) {
+		cdrel_unload_module(backend_type, record_type, configs, backend_name);
+		return NULL;
+	}
+
+	if (load_config_file(backend_type, record_type, configs, filename, 0) != 0) {
+		cdrel_unload_module(backend_type, record_type, configs, backend_name);
+		return NULL;
+	}
+
+	if (register_backend(record_type, backend_name, log_cb)) {
+		cdrel_unload_module(backend_type, record_type, configs, backend_name);
+		return NULL;
+	}
+
+	return configs;
+}
+
+int cdrel_unload_module(enum cdrel_backend_type backend_type, enum cdrel_record_type record_type,
+	struct cdrel_configs *configs, const char *backend_name)
+{
+	if (unregister_backend(record_type, backend_name) != 0) {
+		return -1;
+	}
+
+	AST_VECTOR_RESET(configs, config_free);
+	AST_VECTOR_PTR_FREE(configs);
+
+	return 0;
+}
diff --git a/res/cdrel_custom/formatters.c b/res/cdrel_custom/formatters.c
new file mode 100644
index 0000000000..fc4e22a5dc
--- /dev/null
+++ b/res/cdrel_custom/formatters.c
@@ -0,0 +1,194 @@
+/*
+ * Asterisk -- An open source telephony toolkit.
+ *
+ * Copyright (C) 2026, Sangoma Technologies Corporation
+ *
+ * George Joseph <gjoseph@sangoma.com>
+ *
+ * See http://www.asterisk.org for more information about
+ * the Asterisk project. Please do not directly contact
+ * any of the maintainers of this project for assistance;
+ * the project provides a web site, mailing lists and IRC
+ * channels for your use.
+ *
+ * This program is free software, distributed under the terms of
+ * the GNU General Public License Version 2. See the LICENSE file
+ * at the top of the source tree.
+ */
+
+/*!
+ * \file
+ * \author George Joseph <gjoseph@sangoma.com>
+ *
+ * \brief Formatters
+ *
+ */
+
+#include "cdrel.h"
+#include "asterisk/json.h"
+#include "asterisk/cdr.h"
+#include "asterisk/cel.h"
+
+static char *quote_escaper(const char *value, char quote, char quote_escape, char *qvalue)
+{
+	char *ptr = qvalue;
+	const char *dataptr = value;
+
+	if (!qvalue) {
+		return NULL;
+	}
+
+	while(*dataptr != '\0') {
+		if (*dataptr == quote) {
+			*ptr++ = quote_escape;
+		}
+		*ptr++ = *dataptr++;
+	}
+	*ptr='\0';
+	return qvalue;
+}
+
+static int format_string(struct cdrel_config *config,
+	struct cdrel_field *field, struct cdrel_value *input_value, struct cdrel_value *output_value)
+{
+	int quotes_count = 0;
+	int needs_quoting = ast_test_flag(&field->flags, cdrel_flag_quote);
+	int ix = 0;
+	int input_len = strlen(input_value->values.string ?: "");
+	int res = 0;
+	char *qvalue = NULL;
+	char *evalue = (char *)input_value->values.string;
+
+	output_value->data_type = cdrel_type_string;
+	output_value->field_name = input_value->field_name;
+
+	if (input_len == 0) {
+		output_value->values.string = ast_strdup(needs_quoting ? "\"\"" : "");
+		return 0;
+	}
+
+	for (ix = 0; ix < input_len; ix++) {
+		char c = input_value->values.string[ix];
+		if (c == config->quote[0]) {
+			quotes_count++;
+			needs_quoting = 1;
+		} else if (c == config->separator[0] || c == '\r' || c == '\n') {
+			needs_quoting = 1;
+		}
+	}
+
+	ast_debug(5, "%s: %s=%s %s", cdrel_basename(config->output_filename), input_value->field_name,
+		input_value->values.string, ast_str_tmp(128, cdrel_get_field_flags(&field->flags, &STR_TMP)));
+
+	if (!needs_quoting) {
+		output_value->values.string = ast_strdup(input_value->values.string);
+		return output_value->values.string == NULL ? -1 : 0;
+	}
+
+	/* For every quote_count, we need an extra byte for the quote escape character. */
+	qvalue = ast_alloca(input_len + quotes_count + 1);
+	if (quotes_count) {
+		evalue = quote_escaper(input_value->values.string, config->quote[0], config->quote_escape[0], qvalue);
+	}
+	res = ast_asprintf(&output_value->values.string, "%s%s%s", config->quote, evalue, config->quote);
+	return res < 0 ? -1 : 0;
+}
+
+#define DEFINE_FORMATTER(_name, _field, _type, _fmt) \
+	static int format_ ## _name (struct cdrel_config *config, \
+		struct cdrel_field *field, struct cdrel_value *input_value, struct cdrel_value *output_value) \
+	{ \
+		int res = 0; \
+		char *quote = ""; \
+		if (input_value->data_type != output_value->data_type) { \
+			/* Forward to other formatter */ \
+			return cdrel_field_formatters[output_value->data_type](config, field, input_value, output_value); \
+		} \
+		output_value->field_name = input_value->field_name; \
+		if ((config->quoting_method == cdrel_quoting_method_all || ast_test_flag(&field->flags, cdrel_flag_quote)) \
+			&& !ast_test_flag(&field->flags, cdrel_flag_noquote)) { \
+			quote = config->quote; \
+		} \
+		res = ast_asprintf(&output_value->values.string, "%s" _fmt "%s", quote, input_value->values._field, quote); \
+		output_value->data_type = cdrel_type_string; \
+		return res < 0 ? -1 : 0; \
+	}
+
+DEFINE_FORMATTER(uint32, uint32, uint32_t, "%u")
+DEFINE_FORMATTER(int32, int32, int32_t, "%d")
+DEFINE_FORMATTER(uint64, uint64, uint64_t, "%lu")
+DEFINE_FORMATTER(int64, int64, int64_t, "%ld")
+DEFINE_FORMATTER(float, floater, float, "%.1f")
+
+static int format_timeval(struct cdrel_config *config,
+	struct cdrel_field *field, struct cdrel_value *input_value, struct cdrel_value *output_value)
+{
+	struct ast_tm tm;
+	char tempbuf[64] = { 0, };
+	int res = 0;
+	const char *format = "%Y-%m-%d %T";
+
+	output_value->field_name = input_value->field_name;
+
+	if (field->output_data_type == cdrel_type_int64) {
+		output_value->data_type = cdrel_type_int64;
+		output_value->values.int64 = input_value->values.tv.tv_sec;
+		return format_int64(config, field, output_value, output_value);
+	} else if (field->output_data_type == cdrel_type_float) {
+		output_value->data_type = cdrel_type_float;
+		output_value->values.floater = ((float)input_value->values.tv.tv_sec) + ((float)input_value->values.tv.tv_usec) / 1000000.0;
+		return format_float(config, field, output_value, output_value);
+	} else 	if (!ast_strlen_zero(field->data)) {
+		format = field->data;
+	}
+
+	if (input_value->values.tv.tv_sec > 0) {
+		ast_localtime(&input_value->values.tv, &tm, NULL);
+		ast_strftime(tempbuf, sizeof(tempbuf), format, &tm);
+	}
+	input_value->values.string = tempbuf;
+	input_value->data_type = cdrel_type_string;
+	output_value->data_type = cdrel_type_string;
+	res = format_string(config, field, input_value, output_value);
+	return res;
+}
+
+static int format_amaflags(struct cdrel_config *config,
+	struct cdrel_field *field, struct cdrel_value *input_value, struct cdrel_value *output_value)
+{
+	int res = 0;
+
+	input_value->values.string = (char *)ast_channel_amaflags2string(input_value->values.int64);
+	input_value->data_type = cdrel_type_string;
+	output_value->data_type = cdrel_type_string;
+	res = format_string(config, field, input_value, output_value);
+	return res;
+}
+
+static int format_disposition(struct cdrel_config *config,
+	struct cdrel_field *field, struct cdrel_value *input_value, struct cdrel_value *output_value)
+{
+	int res = 0;
+
+	input_value->values.string = (char *)ast_cdr_disp2str(input_value->values.int64);
+	input_value->data_type = cdrel_type_string;
+	output_value->data_type = cdrel_type_string;
+	res = format_string(config, field, input_value, output_value);
+	return res;
+}
+
+int load_formatters(void)
+{
+	ast_debug(1, "Loading Formatters\n");
+	cdrel_field_formatters[cdrel_type_string] = format_string;
+	cdrel_field_formatters[cdrel_type_int32] = format_int32;
+	cdrel_field_formatters[cdrel_type_uint32] = format_uint32;
+	cdrel_field_formatters[cdrel_type_int64] = format_int64;
+	cdrel_field_formatters[cdrel_type_uint64] = format_uint64;
+	cdrel_field_formatters[cdrel_type_timeval] = format_timeval;
+	cdrel_field_formatters[cdrel_type_float] = format_float;
+	cdrel_field_formatters[cdrel_type_amaflags] = format_amaflags;
+	cdrel_field_formatters[cdrel_type_disposition] = format_disposition;
+
+	return 0;
+}
diff --git a/res/cdrel_custom/getters_cdr.c b/res/cdrel_custom/getters_cdr.c
new file mode 100644
index 0000000000..45667db57e
--- /dev/null
+++ b/res/cdrel_custom/getters_cdr.c
@@ -0,0 +1,116 @@
+/*
+ * Asterisk -- An open source telephony toolkit.
+ *
+ * Copyright (C) 2026, Sangoma Technologies Corporation
+ *
+ * George Joseph <gjoseph@sangoma.com>
+ *
+ * See http://www.asterisk.org for more information about
+ * the Asterisk project. Please do not directly contact
+ * any of the maintainers of this project for assistance;
+ * the project provides a web site, mailing lists and IRC
+ * channels for your use.
+ *
+ * This program is free software, distributed under the terms of
+ * the GNU General Public License Version 2. See the LICENSE file
+ * at the top of the source tree.
+ */
+
+/*!
+ * \file
+ * \author George Joseph <gjoseph@sangoma.com>
+ *
+ * \brief CDR Getters
+ *
+ */
+
+#include "cdrel.h"
+#include "asterisk/cdr.h"
+
+static int cdr_get_string(void *record, struct cdrel_config *config,
+	struct cdrel_field *field, struct cdrel_value *value)
+{
+	struct ast_cdr *cdr = record;
+	value->data_type = field->input_data_type;
+	value->field_name = field->name;
+	value->values.string = CDR_FIELD(cdr, field->field_id);
+	return 0;
+}
+
+#define DEFINE_CDR_GETTER(_sname, _ename, _type) \
+static int cdr_get_ ## _ename (void *record, struct cdrel_config *config, \
+	struct cdrel_field *field, struct cdrel_value *value) \
+{ \
+	struct ast_cdr *cdr = record; \
+	value->data_type = field->input_data_type; \
+	value->field_name = field->name; \
+	value->values._sname = *(_type *)CDR_FIELD(cdr, field->field_id); \
+	return 0; \
+}\
+
+DEFINE_CDR_GETTER(int32, int32, int32_t)
+DEFINE_CDR_GETTER(uint32, uint32, uint32_t)
+DEFINE_CDR_GETTER(int64, int64, int64_t)
+DEFINE_CDR_GETTER(uint64, uint64, uint64_t)
+DEFINE_CDR_GETTER(tv, timeval, struct timeval)
+DEFINE_CDR_GETTER(floater, float, float)
+
+static int cdr_get_literal(void *record, struct cdrel_config *config,
+	struct cdrel_field *field, struct cdrel_value *value)
+{
+	value->data_type = cdrel_type_string;
+	value->field_name = field->name;
+	value->values.string = field->data;
+	return 0;
+}
+
+static int cdr_get_uservar(void *record, struct cdrel_config *config,
+	struct cdrel_field *field, struct cdrel_value *value)
+{
+	struct ast_cdr *cdr = record;
+	struct ast_var_t *variables;
+	const char *rtn = NULL;
+
+	value->data_type = cdrel_type_string;
+	value->field_name = field->name;
+	AST_LIST_TRAVERSE(&cdr->varshead, variables, entries) {
+		if (strcasecmp(field->data, ast_var_name(variables)) == 0) {
+			rtn = ast_var_value(variables);
+		}
+	}
+
+	value->values.string = (char *)S_OR(rtn, "");
+	return 0;
+}
+
+static struct ast_channel *dummy_chan_alloc_cdr(struct cdrel_config *config, void *data)
+{
+	struct ast_cdr *cdr = data;
+	struct ast_channel *dummy = NULL;
+
+	dummy = ast_dummy_channel_alloc();
+	if (!dummy) {
+		ast_log(LOG_ERROR, "Unable to fabricate channel from CDR for '%s'\n",
+			config->output_filename);
+		return NULL;
+	}
+	ast_channel_cdr_set(dummy, ast_cdr_dup(cdr));
+	return dummy;
+}
+
+int load_cdr(void)
+{
+	ast_debug(1, "Loading CDR getters\n");
+	cdrel_field_getters[cdrel_record_cdr][cdrel_type_string] = cdr_get_string;
+	cdrel_field_getters[cdrel_record_cdr][cdrel_type_literal] = cdr_get_literal;
+	cdrel_field_getters[cdrel_record_cdr][cdrel_type_int32] = cdr_get_int32;
+	cdrel_field_getters[cdrel_record_cdr][cdrel_type_uint32] = cdr_get_uint32;
+	cdrel_field_getters[cdrel_record_cdr][cdrel_type_int64] = cdr_get_int64;
+	cdrel_field_getters[cdrel_record_cdr][cdrel_type_uint64] = cdr_get_uint64;
+	cdrel_field_getters[cdrel_record_cdr][cdrel_type_timeval] = cdr_get_timeval;
+	cdrel_field_getters[cdrel_record_cdr][cdrel_type_float] = cdr_get_float;
+	cdrel_field_getters[cdrel_record_cdr][cdrel_type_uservar] = cdr_get_uservar;
+	cdrel_dummy_channel_allocators[cdrel_record_cdr] = dummy_chan_alloc_cdr;
+
+	return 0;
+}
diff --git a/res/cdrel_custom/getters_cel.c b/res/cdrel_custom/getters_cel.c
new file mode 100644
index 0000000000..d95b6f17a5
--- /dev/null
+++ b/res/cdrel_custom/getters_cel.c
@@ -0,0 +1,116 @@
+/*
+ * Asterisk -- An open source telephony toolkit.
+ *
+ * Copyright (C) 2026, Sangoma Technologies Corporation
+ *
+ * George Joseph <gjoseph@sangoma.com>
+ *
+ * See http://www.asterisk.org for more information about
+ * the Asterisk project. Please do not directly contact
+ * any of the maintainers of this project for assistance;
+ * the project provides a web site, mailing lists and IRC
+ * channels for your use.
+ *
+ * This program is free software, distributed under the terms of
+ * the GNU General Public License Version 2. See the LICENSE file
+ * at the top of the source tree.
+ */
+
+/*!
+ * \file
+ * \author George Joseph <gjoseph@sangoma.com>
+ *
+ * \brief CEL Getters
+ *
+ */
+
+#include "cdrel.h"
+#include "asterisk/cel.h"
+
+static int cel_get_string(void *record, struct cdrel_config *config,
+	struct cdrel_field *field, struct cdrel_value *value)
+{
+	struct ast_event *event = record;
+	value->data_type = cdrel_type_string;
+	value->field_name = field->name;
+	value->values.string = (char *)ast_event_get_ie_str(event, field->field_id);
+	return 0;
+}
+
+static int cel_get_literal(void *record, struct cdrel_config *config,
+	struct cdrel_field *field, struct cdrel_value *value)
+{
+	value->data_type = cdrel_type_string;
+	value->field_name = field->name;
+	value->values.string = field->data;
+	return 0;
+}
+
+static int cel_get_timeval(void *record, struct cdrel_config *config,
+	struct cdrel_field *field, struct cdrel_value *value)
+{
+	struct ast_event *event = record;
+	value->data_type = cdrel_type_timeval;
+	value->field_name = field->name;
+	value->values.tv.tv_sec = ast_event_get_ie_uint(event, AST_EVENT_IE_CEL_EVENT_TIME);
+	value->values.tv.tv_usec = ast_event_get_ie_uint(event, AST_EVENT_IE_CEL_EVENT_TIME_USEC);
+	return 0;
+}
+
+static int cel_get_uint32(void *record, struct cdrel_config *config,
+	struct cdrel_field *field, struct cdrel_value *value)
+{
+	struct ast_event *event = record;
+	value->data_type = cdrel_type_uint32;
+	value->field_name = field->name;
+	value->values.uint32 = ast_event_get_ie_uint(event, field->field_id);
+	return 0;
+}
+
+static int cel_get_event_type(void *record, struct cdrel_config *config,
+	struct cdrel_field *field, struct cdrel_value *value)
+{
+	struct ast_event *event = record;
+	const char *val = NULL;
+	value->data_type = cdrel_type_string;
+	value->field_name = field->name;
+
+	if (ast_event_get_ie_uint(event, AST_EVENT_IE_CEL_EVENT_TYPE) == AST_CEL_USER_DEFINED) {
+		val = ast_event_get_ie_str(event, AST_EVENT_IE_CEL_USEREVENT_NAME);
+	} else {
+		val = ast_cel_get_type_name(ast_event_get_ie_uint(event, AST_EVENT_IE_CEL_EVENT_TYPE));
+	}
+	value->values.string = (char *)val;
+	return 0;
+}
+
+static int cel_get_event_enum(void *record, struct cdrel_config *config,
+	struct cdrel_field *field, struct cdrel_value *value)
+{
+	struct ast_event *event = record;
+	value->data_type = cdrel_type_string;
+	value->field_name = field->name;
+	value->values.string = (char *)ast_cel_get_type_name(ast_event_get_ie_uint(event, AST_EVENT_IE_CEL_EVENT_TYPE));
+	return 0;
+}
+
+static struct ast_channel *dummy_chan_alloc_cel(struct cdrel_config *config, void *data)
+{
+	struct ast_event *event = data;
+
+	return ast_cel_fabricate_channel_from_event(event);
+}
+
+int load_cel(void)
+{
+	ast_debug(1, "Loading CEL getters\n");
+	cdrel_field_getters[cdrel_record_cel][cdrel_type_string] = cel_get_string;
+	cdrel_field_getters[cdrel_record_cel][cdrel_type_literal] = cel_get_literal;
+	cdrel_field_getters[cdrel_record_cel][cdrel_type_uint32] = cel_get_uint32;
+	cdrel_field_getters[cdrel_record_cel][cdrel_type_timeval] = cel_get_timeval;
+	cdrel_field_getters[cdrel_record_cel][cdrel_type_event_type] = cel_get_event_type;
+	cdrel_field_getters[cdrel_record_cel][cdrel_type_event_enum] = cel_get_event_enum;
+	cdrel_dummy_channel_allocators[cdrel_record_cel] = dummy_chan_alloc_cel;
+
+	return 0;
+}
diff --git a/res/cdrel_custom/loggers.c b/res/cdrel_custom/loggers.c
new file mode 100644
index 0000000000..26f9da0262
--- /dev/null
+++ b/res/cdrel_custom/loggers.c
@@ -0,0 +1,307 @@
+/*
+ * Asterisk -- An open source telephony toolkit.
+ *
+ * Copyright (C) 2026, Sangoma Technologies Corporation
+ *
+ * George Joseph <gjoseph@sangoma.com>
+ *
+ * See http://www.asterisk.org for more information about
+ * the Asterisk project. Please do not directly contact
+ * any of the maintainers of this project for assistance;
+ * the project provides a web site, mailing lists and IRC
+ * channels for your use.
+ *
+ * This program is free software, distributed under the terms of
+ * the GNU General Public License Version 2. See the LICENSE file
+ * at the top of the source tree.
+ */
+
+/*!
+ * \file
+ * \author George Joseph <gjoseph@sangoma.com>
+ *
+ * \brief Common log entrypoint from the cdr/cel modules
+ *
+ */
+
+#include "cdrel.h"
+#include "asterisk/pbx.h"
+#include "asterisk/vector.h"
+
+/*
+ * We can save some time and ast_str memory allocation work by allocating a single
+ * thread-local buffer and re-using it.
+ */
+AST_THREADSTORAGE(custom_buf);
+
+/*!
+ * \internal
+ * \brief Free an ast_value object
+ *
+ * ... and if the data type is "string", free it as well.
+ *
+ * \param data
+ */
+static void free_value(void *data)
+{
+	struct cdrel_value *val = data;
+	if (val->data_type == cdrel_type_string) {
+		ast_free(val->values.string);
+		val->values.string = NULL;
+	}
+	ast_free(val);
+}
+
+/*!
+ * \internal
+ * \brief Free a vector of cdrel_values
+ *
+ * \param data
+ */
+static void free_value_vector(void *data)
+{
+	struct cdrel_values *values = data;
+	AST_VECTOR_RESET(values, free_value);
+	AST_VECTOR_PTR_FREE(values);
+}
+
+/*!
+ * \internal
+ * \brief Log a legacy record to a file.
+ *
+ * The file legacy format specifies one long string with dialplan functions.  We have no idea
+ * what the separator is so we need to pass the entire string to ast_str_substitute_variables.
+ * This is where the cycles are spent.  We then write the result directly to the backend
+ * file bypassing all of the advanced processing.
+ *
+ * \param config The configuration object.
+ * \param data The data to write. May be an ast_cdr or an ast_event.
+ * \retval 0 on success
+ * \retval -1 on failure
+ */
+static int log_legacy_dsv_record(struct cdrel_config *config, void *data)
+{
+	struct ast_channel *dummy = data;
+	struct ast_str *str;
+
+	if (!(str = ast_str_thread_get(&custom_buf, 1024))) {
+		return -1;
+	}
+	ast_str_reset(str);
+
+	ast_str_substitute_variables(&str, 0, dummy, config->template);
+
+	return write_record_to_file(config, str);
+}
+
+/*!
+ * \internal
+ * \brief Log a legacy record to a database.
+ *
+ * Unlike the file backends, the legacy database backend configs always use commas
+ * as field separators but they all still use dialplan functions so we need still
+ * need to do evaluation and substitution.  Since we know the separator however,
+ * we can iterate over the individual fields.
+ *
+ * \param config The configuration object.
+ * \param data The data to write. May be an ast_cdr or an ast_event.
+ * \retval 0 on success
+ * \retval -1 on failure
+ */
+static int log_legacy_database_record(struct cdrel_config *config, void *data)
+{
+	struct ast_channel *dummy = data;
+	int ix = 0;
+	int res = 0;
+	char subst_buf[2048];
+	size_t field_count = AST_VECTOR_SIZE(&config->fields);
+	RAII_VAR(struct cdrel_values *, values, ast_calloc(1, sizeof(*values)), free_value_vector);
+
+	if (!values) {
+		return -1;
+	}
+
+	res = AST_VECTOR_INIT(values, field_count);
+	if (res != 0) {
+		return -1;
+	}
+
+	if (config->db == NULL) {
+		return -1;
+	}
+
+	for (ix = 0; ix < AST_VECTOR_SIZE(&config->fields); ix++) {
+		struct cdrel_field *field = AST_VECTOR_GET(&config->fields, ix);
+		struct cdrel_value *output_value = ast_calloc(1, sizeof(*output_value));
+
+		if (!output_value) {
+			return -1;
+		}
+		output_value->mallocd = 1;
+
+		pbx_substitute_variables_helper(dummy, field->data, subst_buf, sizeof(subst_buf) - 1);
+		output_value->data_type = cdrel_type_string;
+
+		output_value->field_name = field->name;
+		output_value->values.string = ast_strdup(ast_strip_quoted(subst_buf,  "'\"", "'\""));
+		if (!output_value->values.string) {
+			return -1;
+		}
+
+		res = AST_VECTOR_APPEND(values, output_value);
+		if (res != 0) {
+			ast_free(output_value);
+			return -1;
+		}
+	}
+
+	return write_record_to_database(config, values);
+}
+
+/*!
+ * \internal
+ * \brief Log an advanced record
+ *
+ * For the file advanced formats, we know what the field separator is so we
+ * iterate over them and accumulate the results in a vector of cdrel_values.
+ * No dialplan function evaluation needed.
+ *
+ * \param config The configuration object.
+ * \param data The data to log. May be an ast_cdr or an ast_event.
+ * \retval 0 on success
+ * \retval -1 on failure
+ */
+static int log_advanced_record(struct cdrel_config *config, void *data)
+{
+	int ix = 0;
+	int res = 0;
+	size_t field_count = AST_VECTOR_SIZE(&config->fields);
+	RAII_VAR(struct cdrel_values *, values, ast_calloc(1, sizeof(*values)), free_value_vector);
+
+	if (!values) {
+		return -1;
+	}
+
+	res = AST_VECTOR_INIT(values, field_count);
+	if (res != 0) {
+		return -1;
+	}
+
+	for (ix = 0; ix < AST_VECTOR_SIZE(&config->fields); ix++) {
+		struct cdrel_field *field = AST_VECTOR_GET(&config->fields, ix);
+		struct cdrel_value input_value = { 0, };
+		struct cdrel_value *output_value = ast_calloc(1, sizeof(*output_value));
+
+		if (!output_value) {
+			return -1;
+		}
+		output_value->mallocd = 1;
+
+		/*
+		 * Get a field from a CDR structure or CEL event into an cdrel_value.
+		 */
+		res = cdrel_field_getters[config->record_type][field->input_data_type](data, config, field, &input_value);
+		if (res != 0) {
+			ast_free(output_value);
+			return -1;
+		}
+
+		/*
+		 * Set the output data type to the type we want to see in the output.
+		 */
+		output_value->data_type = field->output_data_type;
+
+		/*
+		 * Now call the formatter based on the INPUT data type.
+		 */
+		res = cdrel_field_formatters[input_value.data_type](config, field, &input_value, output_value);
+		if (res != 0) {
+			ast_free(output_value);
+			return -1;
+		}
+
+		res = AST_VECTOR_APPEND(values, output_value);
+		if (res != 0) {
+			ast_free(output_value);
+			return -1;
+		}
+	}
+	return cdrel_backend_writers[config->format_type](config, values);
+}
+
+/*
+ * These callbacks are only used in this file so there's no need to
+ * make them available to the rest of the module.
+ */
+typedef int (*cdrel_logger_cb)(struct cdrel_config *config, void *data);
+
+static const cdrel_logger_cb logger_callbacks[cdrel_backend_type_end][cdrel_config_type_end] = {
+	[cdrel_backend_text] = {
+		[cdrel_config_legacy] = log_legacy_dsv_record,
+		[cdrel_config_advanced] = log_advanced_record
+	},
+	[cdrel_backend_db] = {
+		[cdrel_config_legacy] = log_legacy_database_record,
+		[cdrel_config_advanced] = log_advanced_record
+	},
+};
+
+/*!
+ * \internal
+ * \brief Main logging entrypoint from the individual modules.
+ *
+ * This is the entrypoint from the individual cdr and cel modules.
+ * "data" will either be an ast_cdr or ast_event structure but we
+ * don't actually care at this point.
+ *
+ * For legacy configs, we need to create a dummy channel so we'll
+ * do that if/when we hit the first one and we'll reuse it for all
+ * further legacy configs.  If we fail to get a channel, we'll skip
+ * all further configs.
+ *
+ * \warning This function MUST be called with the module's config_lock
+ * held for reading to prevent reloads from happening while we're logging.
+ *
+ * \param configs The calling module's vector of configuration objects.
+ * \param data The data to write. May be an ast_cdr or an ast_event.
+ * \retval 0 on success
+ * \retval <0 on failure. The magnitude indicates how many configs failed.
+ */
+int cdrel_logger(struct cdrel_configs *configs, void *data)
+{
+	struct ast_channel *dummy = NULL;
+	int ix = 0;
+	int skip_legacy = 0;
+	int res = 0;
+
+	for(ix = 0; ix < AST_VECTOR_SIZE(configs); ix++) {
+		struct cdrel_config *config = AST_VECTOR_GET(configs, ix);
+		void *chan_or_data = NULL;
+
+		if (config->config_type == cdrel_config_legacy) {
+			if (skip_legacy) {
+				continue;
+			}
+			if (!dummy) {
+				dummy = config->dummy_channel_alloc(config, data);
+				if (!dummy) {
+					ast_log(LOG_ERROR, "Unable to fabricate channel from CEL event for '%s'\n",
+						config->output_filename);
+					skip_legacy = 1;
+					res--;
+					continue;
+				}
+			}
+			chan_or_data = dummy;
+		} else {
+			chan_or_data = data;
+		}
+		res += logger_callbacks[config->backend_type][config->config_type](config, chan_or_data);
+	}
+
+	if (dummy) {
+		ast_channel_unref(dummy);
+	}
+	return res;
+}
+
diff --git a/res/cdrel_custom/registry.c b/res/cdrel_custom/registry.c
new file mode 100644
index 0000000000..472b147c45
--- /dev/null
+++ b/res/cdrel_custom/registry.c
@@ -0,0 +1,123 @@
+/*
+ * Asterisk -- An open source telephony toolkit.
+ *
+ * Copyright (C) 2026, Sangoma Technologies Corporation
+ *
+ * George Joseph <gjoseph@sangoma.com>
+ *
+ * See http://www.asterisk.org for more information about
+ * the Asterisk project. Please do not directly contact
+ * any of the maintainers of this project for assistance;
+ * the project provides a web site, mailing lists and IRC
+ * channels for your use.
+ *
+ * This program is free software, distributed under the terms of
+ * the GNU General Public License Version 2. See the LICENSE file
+ * at the top of the source tree.
+ */
+
+/*!
+ * \file
+ * \author George Joseph <gjoseph@sangoma.com>
+ *
+ * \brief CDR/CEL field registry
+ *
+ */
+
+#include "cdrel.h"
+
+#include "asterisk/json.h"
+#include "asterisk/cdr.h"
+#include "asterisk/cel.h"
+
+/*!
+ * \internal
+ * \brief Helper macro that populates the static array of cdrel_fields.
+ *
+ * \param _record_type The type of record: CDR or CEL.
+ * \param _field_id For CEL, it's the event field.  For CDR it's one of cdr_field_id.
+ * \param _name The field name.
+ * \param _input_type The input data type.  Drives the getters.
+ * \param output_types An array of types, one each for dsv, json and sql.  Drives the formatters.
+ * \param _mallocd Not used.
+ */
+#define REGISTER_FIELD(_record_type, _field_id, _name, _input_type, _output_type) \
+	{ _record_type, _field_id, _name, _input_type, _output_type, { 0 } }
+
+static const struct cdrel_field cdrel_field_registry[] = {
+	REGISTER_FIELD(cdrel_record_cel, AST_EVENT_IE_CEL_EVENT_ENUM, "eventenum", cdrel_type_event_enum, cdrel_type_string),
+	REGISTER_FIELD(cdrel_record_cel, AST_EVENT_IE_CEL_EVENT_TYPE, "eventtype", cdrel_type_event_type, cdrel_type_string),
+	REGISTER_FIELD(cdrel_record_cel, AST_EVENT_IE_CEL_EVENT_TIME, "eventtime", cdrel_type_timeval, cdrel_type_string),
+	REGISTER_FIELD(cdrel_record_cel, AST_EVENT_IE_CEL_EVENT_TIME_USEC, "eventtimeusec", cdrel_type_uint32, cdrel_type_uint32),
+	REGISTER_FIELD(cdrel_record_cel, AST_EVENT_IE_CEL_USEREVENT_NAME, "usereventname", cdrel_type_string, cdrel_type_string),
+	REGISTER_FIELD(cdrel_record_cel, AST_EVENT_IE_CEL_USEREVENT_NAME, "userdeftype", cdrel_type_string, cdrel_type_string),
+	REGISTER_FIELD(cdrel_record_cel, AST_EVENT_IE_CEL_CIDNAME, "name", cdrel_type_string, cdrel_type_string),
+	REGISTER_FIELD(cdrel_record_cel, AST_EVENT_IE_CEL_CIDNUM, "num", cdrel_type_string, cdrel_type_string),
+	REGISTER_FIELD(cdrel_record_cel, AST_EVENT_IE_CEL_EXTEN, "exten", cdrel_type_string, cdrel_type_string),
+	REGISTER_FIELD(cdrel_record_cel, AST_EVENT_IE_CEL_CONTEXT, "context", cdrel_type_string, cdrel_type_string),
+	REGISTER_FIELD(cdrel_record_cel, AST_EVENT_IE_CEL_CHANNAME, "channame", cdrel_type_string, cdrel_type_string),
+	REGISTER_FIELD(cdrel_record_cel, AST_EVENT_IE_CEL_APPNAME, "appname", cdrel_type_string, cdrel_type_string),
+	REGISTER_FIELD(cdrel_record_cel, AST_EVENT_IE_CEL_APPDATA, "appdata", cdrel_type_string, cdrel_type_string),
+	REGISTER_FIELD(cdrel_record_cel, AST_EVENT_IE_CEL_AMAFLAGS, "amaflags", cdrel_type_uint32, cdrel_type_uint32),
+	REGISTER_FIELD(cdrel_record_cel, AST_EVENT_IE_CEL_ACCTCODE, "accountcode", cdrel_type_string, cdrel_type_string),
+	REGISTER_FIELD(cdrel_record_cel, AST_EVENT_IE_CEL_UNIQUEID, "uniqueid", cdrel_type_string, cdrel_type_string),
+	REGISTER_FIELD(cdrel_record_cel, AST_EVENT_IE_CEL_USERFIELD, "userfield", cdrel_type_string, cdrel_type_string),
+	REGISTER_FIELD(cdrel_record_cel, AST_EVENT_IE_CEL_CIDANI, "ani", cdrel_type_string, cdrel_type_string),
+	REGISTER_FIELD(cdrel_record_cel, AST_EVENT_IE_CEL_CIDRDNIS, "rdnis", cdrel_type_string, cdrel_type_string),
+	REGISTER_FIELD(cdrel_record_cel, AST_EVENT_IE_CEL_CIDDNID, "dnid", cdrel_type_string, cdrel_type_string),
+	REGISTER_FIELD(cdrel_record_cel, AST_EVENT_IE_CEL_PEER, "peer", cdrel_type_string, cdrel_type_string),
+	REGISTER_FIELD(cdrel_record_cel, AST_EVENT_IE_CEL_PEER, "bridgepeer", cdrel_type_string, cdrel_type_string),
+	REGISTER_FIELD(cdrel_record_cel, AST_EVENT_IE_CEL_LINKEDID, "linkedid", cdrel_type_string, cdrel_type_string),
+	REGISTER_FIELD(cdrel_record_cel, AST_EVENT_IE_CEL_PEERACCT, "peeraccount", cdrel_type_string, cdrel_type_string),
+	REGISTER_FIELD(cdrel_record_cel, AST_EVENT_IE_CEL_EXTRA, "eventextra", cdrel_type_string, cdrel_type_string),
+	REGISTER_FIELD(cdrel_record_cel, AST_EVENT_IE_CEL_TENANTID, "tenantid", cdrel_type_string, cdrel_type_string),
+	REGISTER_FIELD(cdrel_record_cel, AST_EVENT_IE_CEL_LITERAL, "literal", cdrel_type_literal, cdrel_type_string),
+
+	REGISTER_FIELD(cdrel_record_cdr, cdr_field_clid, "clid", cdrel_type_string, cdrel_type_string),
+	REGISTER_FIELD(cdrel_record_cdr, cdr_field_src, "src", cdrel_type_string, cdrel_type_string),
+	REGISTER_FIELD(cdrel_record_cdr, cdr_field_dst, "dst", cdrel_type_string, cdrel_type_string),
+	REGISTER_FIELD(cdrel_record_cdr, cdr_field_dcontext, "dcontext", cdrel_type_string, cdrel_type_string),
+	REGISTER_FIELD(cdrel_record_cdr, cdr_field_channel, "channel", cdrel_type_string, cdrel_type_string),
+	REGISTER_FIELD(cdrel_record_cdr, cdr_field_dstchannel, "dstchannel", cdrel_type_string, cdrel_type_string),
+	REGISTER_FIELD(cdrel_record_cdr, cdr_field_lastapp, "lastapp", cdrel_type_string, cdrel_type_string),
+	REGISTER_FIELD(cdrel_record_cdr, cdr_field_lastdata, "lastdata", cdrel_type_string, cdrel_type_string),
+	REGISTER_FIELD(cdrel_record_cdr, cdr_field_start, "start", cdrel_type_timeval, cdrel_type_string),
+	REGISTER_FIELD(cdrel_record_cdr, cdr_field_answer, "answer", cdrel_type_timeval, cdrel_type_string),
+	REGISTER_FIELD(cdrel_record_cdr, cdr_field_end, "end", cdrel_type_timeval, cdrel_type_string),
+	REGISTER_FIELD(cdrel_record_cdr, cdr_field_duration, "duration", cdrel_type_int64, cdrel_type_int64),
+	REGISTER_FIELD(cdrel_record_cdr, cdr_field_billsec, "billsec", cdrel_type_int64, cdrel_type_int64),
+	REGISTER_FIELD(cdrel_record_cdr, cdr_field_disposition, "disposition", cdrel_type_int64, cdrel_type_disposition),
+	REGISTER_FIELD(cdrel_record_cdr, cdr_field_amaflags, "amaflags", cdrel_type_int64, cdrel_type_amaflags),
+	REGISTER_FIELD(cdrel_record_cdr, cdr_field_accountcode, "accountcode", cdrel_type_string, cdrel_type_string),
+	REGISTER_FIELD(cdrel_record_cdr, cdr_field_peeraccount, "peeraccount", cdrel_type_string, cdrel_type_string),
+	REGISTER_FIELD(cdrel_record_cdr, cdr_field_flags, "flags", cdrel_type_uint32, cdrel_type_uint32),
+	REGISTER_FIELD(cdrel_record_cdr, cdr_field_uniqueid, "uniqueid", cdrel_type_string, cdrel_type_string),
+	REGISTER_FIELD(cdrel_record_cdr, cdr_field_linkedid, "linkedid", cdrel_type_string, cdrel_type_string),
+	REGISTER_FIELD(cdrel_record_cdr, cdr_field_tenantid, "tenantid", cdrel_type_string, cdrel_type_string),
+	REGISTER_FIELD(cdrel_record_cdr, cdr_field_peertenantid, "peertenantid", cdrel_type_string, cdrel_type_string),
+	REGISTER_FIELD(cdrel_record_cdr, cdr_field_userfield, "userfield", cdrel_type_string, cdrel_type_string),
+	REGISTER_FIELD(cdrel_record_cdr, cdr_field_sequence, "sequence", cdrel_type_int32, cdrel_type_int32),
+	REGISTER_FIELD(cdrel_record_cdr, cdr_field_varshead, "uservar", cdrel_type_uservar, cdrel_type_string),
+	REGISTER_FIELD(cdrel_record_cdr, cdr_field_literal, "literal", cdrel_type_literal, cdrel_type_string),
+};
+
+/*
+* \internal
+* \brief Get a cdrel_field structure by record type and field name.
+*
+* \param record_type The cdrel_record_type to search.
+* \param name The field name to search for.
+* \returns A pointer to a constant cdrel_field structure or NULL if not found.
+*          This pointer must never be freed.
+*/
+const struct cdrel_field *get_registered_field_by_name(enum cdrel_record_type type, const char *name)
+{
+	int ix = 0;
+
+	for (ix = 0; ix < ARRAY_LEN(cdrel_field_registry); ix++) {
+		if (cdrel_field_registry[ix].record_type == type && strcasecmp(cdrel_field_registry[ix].name, name) == 0) {
+			return &cdrel_field_registry[ix];
+		}
+	}
+	return NULL;
+}
diff --git a/res/cdrel_custom/writers.c b/res/cdrel_custom/writers.c
new file mode 100644
index 0000000000..975302c042
--- /dev/null
+++ b/res/cdrel_custom/writers.c
@@ -0,0 +1,230 @@
+/*
+ * Asterisk -- An open source telephony toolkit.
+ *
+ * Copyright (C) 2026, Sangoma Technologies Corporation
+ *
+ * George Joseph <gjoseph@sangoma.com>
+ *
+ * See http://www.asterisk.org for more information about
+ * the Asterisk project. Please do not directly contact
+ * any of the maintainers of this project for assistance;
+ * the project provides a web site, mailing lists and IRC
+ * channels for your use.
+ *
+ * This program is free software, distributed under the terms of
+ * the GNU General Public License Version 2. See the LICENSE file
+ * at the top of the source tree.
+ */
+
+/*!
+ * \file
+ * \author George Joseph <gjoseph@sangoma.com>
+ *
+ * \brief Backend output functions.
+ *
+ * The writers all take a vector of cdrel_value objects and write them to the output
+ * file or database.
+ *
+ */
+
+#include "cdrel.h"
+
+/*
+ * We can save some time and ast_str memory allocation work by allocating a single
+ * thread-local buffer and re-using it.
+ */
+AST_THREADSTORAGE(custom_buf);
+
+/*!
+ * \internal
+ * \brief Write a record to a text file
+ *
+ * Besides being used here, this function is also used by the legacy loggers
+ * that shortcut the advanced stuff.
+ *
+ * \param config The configuration object.
+ * \param record The data to write.
+ * \retval 0 on success
+ * \retval -1 on failure
+ */
+int write_record_to_file(struct cdrel_config *config, struct ast_str *record)
+{
+	FILE *out;
+	int res = 0;
+
+	ast_mutex_lock(&config->lock);
+	if ((out = fopen(config->output_filename, "a"))) {
+		res = fputs(ast_str_buffer(record), out);
+		fputs("\n", out);
+		fclose(out);
+	}
+	ast_mutex_unlock(&config->lock);
+
+	if (!out || res < 0) {
+		ast_log(LOG_ERROR, "Unable to write %s to file %s : %s\n",
+			RECORD_TYPE_STR(config->record_type), config->output_filename, strerror(errno));
+		return -1;
+	}
+	return 0;
+}
+
+/*!
+ * \internal
+ * \brief Concatenate and append a list of values to an ast_str
+ *
+ * \param config Config object
+ * \param values A vector of values
+ * \param str The ast_str to append to
+ * \retval 0 on success
+ * \retval -1 on failure
+ */
+static int dsv_appender(struct cdrel_config *config, struct cdrel_values *values, struct ast_str **str)
+{
+	int ix = 0;
+	int res = 0;
+
+	for (ix = 0; ix < AST_VECTOR_SIZE(values); ix++) {
+		struct cdrel_value *value = AST_VECTOR_GET(values, ix);
+		ast_assert(value->data_type == cdrel_type_string);
+
+		res = ast_str_append(str, -1, "%s%s", ix == 0 ? "" : config->separator, value->values.string);
+		if (res < 0) {
+			return -1;
+		}
+	}
+
+	return res;
+}
+
+/*!
+ * \internal
+ * \brief Write a DSV list of values to a text file
+ *
+ * \param config Config object
+ * \param values A vector of values
+ * \retval 0 on success
+ * \retval -1 on failure
+ */
+static int dsv_writer(struct cdrel_config *config, struct cdrel_values *values)
+{
+	int res = 0;
+	struct ast_str *str = ast_str_thread_get(&custom_buf, 1024);
+
+	ast_str_reset(str);
+
+	res = dsv_appender(config, values, &str);
+	if (res < 0) {
+		return -1;
+	}
+
+	return write_record_to_file(config, str);
+}
+
+/*!
+ * \internal
+ * \brief Write a list of values as a JSON object to a text file
+ *
+ * \param config Config object
+ * \param values A vector of values
+ * \retval 0 on success
+ * \retval -1 on failure
+ *
+ * \note We are intentionally NOT using the ast_json APIs here because
+ * they're expensive and these are simple objects.
+ */
+static int json_writer(struct cdrel_config *config, struct cdrel_values *values)
+{
+	int ix = 0;
+	int res = 0;
+	struct ast_str *str = ast_str_thread_get(&custom_buf, 1024);
+
+	ast_str_set(&str, -1, "%s", "{");
+
+	for (ix = 0; ix < AST_VECTOR_SIZE(values); ix++) {
+		struct cdrel_value *value = AST_VECTOR_GET(values, ix);
+		ast_assert(value->data_type == cdrel_type_string);
+
+		res = ast_str_append(&str, -1, "%s\"%s\":%s", ix == 0 ? "" : config->separator, value->field_name, value->values.string);
+		if (res < 0) {
+			return -1;
+		}
+	}
+	ast_str_append(&str, -1, "%s", "}");
+
+	return write_record_to_file(config, str);
+}
+
+/*!
+ * \internal
+ * \brief Write a record to a database
+ *
+ * Besides being used here, this function is also used by the legacy loggers
+ * that shortcut the advanced stuff.
+ *
+ * \param config The configuration object.
+ * \param record The data to write.
+ * \retval 0 on success
+ * \retval -1 on failure
+ */
+int write_record_to_database(struct cdrel_config *config, struct cdrel_values *values)
+{
+	int res = 0;
+	int ix;
+
+	ast_mutex_lock(&config->lock);
+	for (ix = 0; ix < AST_VECTOR_SIZE(values); ix++) {
+		struct cdrel_value *value = AST_VECTOR_GET(values, ix);
+		ast_assert(value->data_type == cdrel_type_string);
+		ast_debug(6, "%s '%s'\n", value->field_name, value->values.string);
+		res = sqlite3_bind_text(config->insert, ix + 1, value->values.string, -1, SQLITE_STATIC);
+		if (res != SQLITE_OK) {
+			ast_log(LOG_ERROR, "Unable to write %s to database %s.  SQL bind for field %s:'%s'. Error: %s\n",
+				RECORD_TYPE_STR(config->record_type), config->output_filename,
+				value->field_name, value->values.string,
+				sqlite3_errmsg(config->db));
+			sqlite3_reset(config->insert);
+			ast_mutex_unlock(&config->lock);
+			return -1;
+		}
+	}
+
+	res = sqlite3_step(config->insert);
+	if (res != SQLITE_DONE) {
+		ast_log(LOG_ERROR, "Unable to write %s to database %s. Error: %s\n",
+			RECORD_TYPE_STR(config->record_type), config->output_filename,
+			sqlite3_errmsg(config->db));
+		sqlite3_reset(config->insert);
+		ast_mutex_unlock(&config->lock);
+		return -1;
+	}
+
+	sqlite3_reset(config->insert);
+	ast_mutex_unlock(&config->lock);
+
+	return res;
+}
+
+/*!
+ * \internal
+ * \brief Write a list of values to a database
+ *
+ * \param config Config object
+ * \param values A vector of values
+ * \retval 0 on success
+ * \retval -1 on failure
+ */
+static int database_writer(struct cdrel_config *config, struct cdrel_values *values)
+{
+	return write_record_to_database(config, values);
+}
+
+int load_writers(void)
+{
+	ast_debug(1, "Loading Writers\n");
+	cdrel_backend_writers[cdrel_format_dsv] = dsv_writer;
+	cdrel_backend_writers[cdrel_format_json] = json_writer;
+	cdrel_backend_writers[cdrel_format_sql] = database_writer;
+
+	return 0;
+}
+
diff --git a/res/res_cdrel_custom.c b/res/res_cdrel_custom.c
new file mode 100644
index 0000000000..179db87acc
--- /dev/null
+++ b/res/res_cdrel_custom.c
@@ -0,0 +1,257 @@
+/*
+ * Asterisk -- An open source telephony toolkit.
+ *
+ * Copyright (C) 2026, Sangoma Technologies Corporation
+ *
+ * George Joseph <gjoseph@sangoma.com>
+ *
+ * See http://www.asterisk.org for more information about
+ * the Asterisk project. Please do not directly contact
+ * any of the maintainers of this project for assistance;
+ * the project provides a web site, mailing lists and IRC
+ * channels for your use.
+ *
+ * This program is free software, distributed under the terms of
+ * the GNU General Public License Version 2. See the LICENSE file
+ * at the top of the source tree.
+ */
+
+/*!
+ * \file
+ * \author George Joseph <gjoseph@sangoma.com>
+ *
+ * \brief Common logic for the CDR and CEL Custom Backends
+ *
+ * All source files are in the res/cdrel_custom directory.
+ *
+ * "config.c": Contains common configuration file parsing the ultimate goal
+ * of which is to create a vector of cdrel_config structures for each of
+ * the cdr_custom, cdr_sqlite3_custom, cel_custom and cel_sqlite3_custom
+ * modules. Each cdrel_config object represents an output file defined in
+ * their respective config files.  Each one contains a vector of cdrel_field
+ * objects, one for each field in the output record, plus settings like
+ * the output file name, backend type (text file or database), config
+ * type ((legacy or advanced), the field separator and quote character to
+ * use.
+ *
+ * Each cdrel_field object contains an abstract field id that points to
+ * a ast_cdr structure member or CEL event field id along with an input
+ * type and an output type.  The registry of cdrel_fields is located in
+ * registry.c.
+ *
+ * "loggers.c": Contains the common "cdrel_logger" entrypoint that the
+ * individual modules call to log a record.  It takes the module's
+ * cdrel_configs vector and the record to log it got from the core
+ * cel.c and cdr.c.  It then looks up and runs the logger implementation
+ * based on the backend type (text file or database) and config type
+ * (legacy or advanced).
+ *
+ * "getters_cdr.c", "getters_cel.c": Contain the getters that retrieve
+ * values from the ast_cdr or ast_event structures based on the field
+ * id and input type defined for that field and create a cdrel_value
+ * wrapper object for it.
+ *
+ * "writers.c": Contains common backend writers for the text file and
+ * database backends.
+ *
+ * The load-time flow...
+ *
+ * Each of the individual cdr/cel custom modules call the common
+ * cdrel_load_module function with their backend_type, record_type
+ * (cdr or cel), config file name, and the logging callback that
+ * should be registered with the core cdr or cel facility.
+ *
+ * cdrel_load_module calls the config load function appropriate for
+ * the backend type, each of which parses the config file and, if
+ * successful, registers the calling module with the cdr or cel core
+ * and creates a vector of cdrel_config objects that is passed
+ * back to the calling module.  That vector contains the context for
+ * all future operations.
+ *
+ * The run-time flow...
+ *
+ * The core cdr and cel modules use their registries of backends and call
+ * the callback function registered by the 4 cdr and cel custom modules.
+ * No changes there.
+ *
+ * Each of those modules call the common cdrel_logger function with their
+ * cdrel_configs vector and the actual ast_cdr or ast_event structure to log.
+ * The cdrel_logger function iterates over the cdrel_configs vector and for
+ * each invokes the logger implementation specific to the backend type
+ * (text file or database) and config type (legacy or advanced).
+ *
+ * For legacy config types, the logger implementation simply calls
+ * ast_str_substitute_variables() on the whole opaque format and writes
+ * the result to the text file or database.
+ *
+ * For advanced configs, the logger implementation iterates over each field
+ * in the cdrel_config's fields vector and for each, calls the appropriate
+ * getter based on the record type (cdr or cel) and field id. Each getter
+ * call returns a cdrel_value object which is then passed to a field formatter
+ * looked up based on the field's data type (string, int32, etc). The formatter
+ * is also passed the cdrel_config object and the desired output type and
+ * returns the final value in another cdrel_value object formatted with any
+ * quoting, etc. needed.  The logger accumulates the output cdrel_values
+ * (which are all now strings) in another vector and after all fields have
+ * been processed, hands the vector over to one of the backend writers.
+ *
+ * The backend writer concatenates the cdrel_values into an output record
+ * using the config's separator setting and writes it to the text file
+ * or database.  For the JSON output format, it creates a simple
+ * name/value pair output record.
+ *
+ * The identification of field data types, field ids, record types and
+ * backend types is all done at config load time and saved in the
+ * cdrel_config and cdrel_field objects. The callbacks for getters, formatters
+ * and writers are also loaded when the res_cdrel_custom module loads
+ * and stored in arrays indexed by their enum values.  The result is that
+ * at run time, simple array indexing is all that's needed to get the
+ * proper getter, formatter and writer for any logging request.
+ *
+ */
+
+/*** MODULEINFO
+	<depend>sqlite3</depend>
+	<support_level>core</support_level>
+ ***/
+
+
+#include "asterisk.h"
+
+#include "asterisk/module.h"
+#include "cdrel_custom/cdrel.h"
+
+/*
+ * Populated by cdrel_custom/getters_cdr.c and cdrel_custom/getters_cel.c.
+ */
+cdrel_field_getter cdrel_field_getters[cdrel_record_type_end][cdrel_data_type_end];
+
+/*
+ * Populated by cdrel_custom/formatters.c.
+ */
+cdrel_field_formatter cdrel_field_formatters[cdrel_data_type_end];
+
+/*
+ * Populated by cdrel_custom/writers.c.
+ */
+cdrel_backend_writer cdrel_backend_writers[cdrel_format_type_end];
+
+/*
+ * Populated by cdrel_custom/getters_cdr.c and cdrel_custom/getters_cel.c.
+ */
+cdrel_dummy_channel_alloc cdrel_dummy_channel_allocators[cdrel_format_type_end];
+
+/*
+ * You must ensure that there's an entry for every value in the enum.
+ */
+
+const char *cdrel_record_type_map[] = {
+	[cdrel_record_cdr] = "CDR",
+	[cdrel_record_cel] = "CEL",
+	[cdrel_record_type_end] = "!!END!!",
+};
+
+const char *cdrel_module_type_map[] = {
+	[cdrel_backend_text] = "Custom ",
+	[cdrel_backend_db] = "SQLITE3 Custom",
+	[cdrel_backend_type_end] = "!!END!!",
+};
+
+const char *cdrel_data_type_map[] = {
+	[cdrel_type_string] = "string",
+	[cdrel_type_timeval] = "timeval",
+	[cdrel_type_literal] = "literal",
+	[cdrel_type_amaflags] = "amaflags",
+	[cdrel_type_disposition] = "disposition",
+	[cdrel_type_uservar] = "uservar",
+	[cdrel_type_event_type] = "event_type",
+	[cdrel_type_event_enum] = "event_enum",
+	[cdrel_data_type_strings_end] = "!!STRINGS END!!",
+	[cdrel_type_int32] = "int32",
+	[cdrel_type_uint32] = "uint32",
+	[cdrel_type_int64] = "int64",
+	[cdrel_type_uint64] = "uint64",
+	[cdrel_type_float] = "float",
+	[cdrel_data_type_end] = "!!END!!",
+};
+
+enum cdrel_data_type cdrel_data_type_from_str(const char *str)
+{
+	enum cdrel_data_type data_type = 0;
+	for (data_type = 0; data_type < cdrel_data_type_end; data_type++) {
+		if (strcasecmp(cdrel_data_type_map[data_type], str) == 0) {
+			return data_type;
+		}
+
+	}
+	return cdrel_data_type_end;
+}
+
+static const char *cdrel_field_flags_map[] = {
+	[CDREL_FIELD_FLAG_QUOTE] = "quote",
+	[CDREL_FIELD_FLAG_NOQUOTE] = "noquote",
+	[CDREL_FIELD_FLAG_TYPE_FORCED] = "type_forced",
+	[CDREL_FIELD_FLAG_USERVAR] = "uservar",
+	[CDREL_FIELD_FLAG_LITERAL] = "literal",
+	[CDREL_FIELD_FLAG_FORMAT_SPEC] = "format_spec",
+	[CDREL_FIELD_FLAG_LAST] = "LAST",
+};
+
+const char *cdrel_get_field_flags(struct ast_flags *flags, struct ast_str **str)
+{
+	int ix = 0;
+	int res = 0;
+	int trues = 0;
+
+	for (ix = 0; ix < CDREL_FIELD_FLAG_LAST; ix++) {
+		if (ast_test_flag(flags, (1 << ix))) {
+			res = ast_str_append(str, -1, "%s%s", trues++ ? "," : "", cdrel_field_flags_map[ix]);
+			if (res < 0) {
+				return "";
+			}
+		}
+	}
+	return ast_str_buffer(*str);
+}
+
+
+const char *cdrel_basename(const char *path)
+{
+	int i = 0;
+	const char *basename = path;
+
+	if (ast_strlen_zero(path)) {
+		return path;
+	}
+	i = strlen(path) - 1;
+	while(i >= 0) {
+		if (path[i] == '/') {
+			basename = &path[i + 1];
+			break;
+		}
+		i--;
+	}
+	return basename;
+}
+
+static int unload_module(void)
+{
+	return 0;
+}
+
+static enum ast_module_load_result load_module(void)
+{
+	load_cdr();
+	load_cel();
+	load_formatters();
+	load_writers();
+	return AST_MODULE_LOAD_SUCCESS;
+}
+
+AST_MODULE_INFO(ASTERISK_GPL_KEY, AST_MODFLAG_GLOBAL_SYMBOLS | AST_MODFLAG_LOAD_ORDER,
+	"Combined logic for CDR/CEL Custom modules",
+	.support_level = AST_MODULE_SUPPORT_CORE,
+	.load = load_module,
+	.unload = unload_module,
+	.load_pri = AST_MODPRI_CDR_DRIVER,
+);
diff --git a/res/res_cdrel_custom.exports.in b/res/res_cdrel_custom.exports.in
new file mode 100644
index 0000000000..39c45954df
--- /dev/null
+++ b/res/res_cdrel_custom.exports.in
@@ -0,0 +1,6 @@
+{
+	global:
+		LINKER_SYMBOL_PREFIX*cdrel_*;
+	local:
+		*;
+};